diff --git a/CHANGELOG.md b/CHANGELOG.md index d68e892..64df513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased — 0.1.2 +- Completed `TRAINLOG_SYNC_CHARACTERIZATION_V1` without changing production + synchronization, schemas, or formats. Added a real Android V3 exporter → + desktop importer/exporter → fresh Android importer round trip, and completed + current-behavior evidence for stable identities, occurrence/set ordering, + exact replay versus correction, local-only field handling, inter-artifact and + delete-before-send partial failure, request/receipt replay and marking, and + contention on the real private-XDG synchronization lock. V4, draft transport, + general tombstones, coherent generations, causal merge, and durable peer + consumption acknowledgement remain future work. + - Reconciled canonical documentation with the implemented 0.1.2 development state, kept v0.1.1 and future synchronization scope explicit, established English as the repository documentation/release-note language, and added a diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 98f2d6f..562dd8a 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -24,6 +24,7 @@ import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import org.json.JSONObject +import org.json.JSONArray import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -386,6 +387,126 @@ class TrainlogRepositoryDraftTest { assertEquals(1, repo.listSessions().size) } + @Test + fun androidV3RoundTripUsesRealDesktopImporterExporterAndFreshAndroidDatabase() { + val sourceName = "roundtrip-source-${UUID.randomUUID()}.db" + val destinationName = "roundtrip-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + val root = Files.createTempDirectory("trainlog-v3-roundtrip-").toFile() + try { + val repetitions = createExercise( + source, "Round-trip repetitions", RecordingMode.SETS, TrackingMode.REPS, + ) + val continuous = createExercise( + source, "Round-trip continuous", RecordingMode.CONTINUOUS, + TrackingMode.DURATION, + ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM, + ) + val maxExercise = createExercise( + source, "Round-trip maximum", RecordingMode.SETS, TrackingMode.REPS, + ) + val repeatedA = SessionExerciseDraft( + entryId = "sxe_10000000-0000-4000-8000-000000000001", + exercise = repetitions, + equipmentId = "leg_press", + plan = SessionExercisePlan( + sets = 2, reps = 8, weightKg = 42.5, + loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90, + ), + sets = listOf( + SessionSetDraft(reps = 8, weightKg = 40.0), + SessionSetDraft(reps = 6, weightKg = 42.5), + ), + ) + val repeatedB = SessionExerciseDraft( + entryId = "sxe_10000000-0000-4000-8000-000000000002", + exercise = repetitions, + equipmentId = "leg_press", + plan = SessionExercisePlan(sets = 1, reps = 5, restSeconds = 120), + sets = listOf(SessionSetDraft(reps = 5, weightKg = 45.0)), + ) + val continuousEntry = SessionExerciseDraft( + entryId = "sxe_10000000-0000-4000-8000-000000000003", + exercise = continuous, + equipmentId = "treadmill", + continuousDurationSeconds = 780, + speedKmh = 8.5, + distanceKm = 1.8, + ) + assertTrue(source.saveSession(SessionDraft( + listOf(repeatedB, continuousEntry, repeatedA), + )) is SaveSessionResult.Saved) + assertTrue(source.saveSession(SessionDraft( + listOf(SessionExerciseDraft( + entryId = "sxe_10000000-0000-4000-8000-000000000004", + exercise = maxExercise, + equipmentId = "rear_delt_pec_fly", + maxWeightKg = 87.5, + )), + sessionType = SessionType.MAX_TEST, + )) is SaveSessionResult.Saved) + + val androidExport = File(root, "android-v3.json") + val desktopDatabase = File(root, "desktop.sqlite") + val desktopExport = File(root, "desktop-v3.json") + androidExport.writeText(source.buildMobileExportV3Json()) + val bridge = findRepositoryFile("tests/support/roundtrip_desktop_v3.py") + val process = ProcessBuilder( + "python3", bridge.absolutePath, androidExport.absolutePath, + desktopDatabase.absolutePath, desktopExport.absolutePath, + ).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().readText() + assertEquals("desktop bridge failed: $output", 0, process.waitFor()) + + val returned = JSONObject(desktopExport.readText()) + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put("exercises", JSONArray(returned.getJSONArray("exercises").toString())) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals( + MobileSessionImportResult.Applied(2, 0, 0, 0), + destination.applyPcMobileExportV3Json(returned.toString()), + ) + assertEquals( + MobileSessionImportResult.Applied(0, 2, 0, 0), + destination.applyPcMobileExportV3Json(returned.toString()), + ) + + val sourceProjection = semanticV3Projection(JSONObject(androidExport.readText())) + val desktopProjection = semanticV3Projection(returned) + val destinationProjection = semanticV3Projection( + JSONObject(destination.buildMobileExportV3Json()), + ) + assertEquals(sourceProjection, desktopProjection) + assertEquals(sourceProjection, destinationProjection) + + val training = destination.listSessions() + .mapNotNull { destination.getSessionDetail(it.sessionId) } + .single { it.summary.sessionType == SessionType.TRAINING } + assertEquals( + listOf(repeatedB.entryId, continuousEntry.entryId, repeatedA.entryId), + training.exercises.map { it.entryId }, + ) + assertEquals(2, training.exercises.count { it.exerciseId == repetitions.exerciseId }) + assertEquals(listOf(5), training.exercises[0].sets.map { it.reps }) + assertEquals(listOf(8, 6), training.exercises[2].sets.map { it.reps }) + assertEquals(780, training.exercises[1].continuousDurationSeconds) + assertEquals("treadmill", training.exercises[1].equipmentId) + val maximum = destination.listSessions() + .mapNotNull { destination.getSessionDetail(it.sessionId) } + .single { it.summary.sessionType == SessionType.MAX_TEST } + assertEquals(87.5, maximum.exercises.single().maxWeightKg!!, 0.0) + } finally { + source.close() + destination.close() + context.deleteDatabase(sourceName) + context.deleteDatabase(destinationName) + root.deleteRecursively() + } + } + @Test fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() { val repo = openRepository() @@ -2395,6 +2516,31 @@ class TrainlogRepositoryDraftTest { throw AssertionError("unreachable") } + private fun semanticV3Projection(value: JSONObject): String { + val copy = JSONObject(value.toString()) + copy.remove("generated_at") + listOf("exercises" to "exercise_id", "sessions" to "session_id", + "body_observations" to "observation_id").forEach { (arrayName, idName) -> + val source = copy.getJSONArray(arrayName) + val sorted = (0 until source.length()).map { source.getJSONObject(it) } + .sortedBy { it.getString(idName) } + copy.put(arrayName, JSONArray().also { output -> sorted.forEach(output::put) }) + } + return canonicalJson(copy).toString() + } + + private fun canonicalJson(value: Any?): Any = when (value) { + is JSONObject -> JSONObject().also { normalized -> + value.keys().asSequence().toList().sorted().forEach { key -> + normalized.put(key, canonicalJson(value.get(key))) + } + } + is JSONArray -> JSONArray().also { normalized -> + repeat(value.length()) { index -> normalized.put(canonicalJson(value.get(index))) } + } + else -> value ?: JSONObject.NULL + } + /** Exact raw snapshots make idempotence cover every persisted business * table, including stable IDs and FKs that higher-level projections omit. */ private fun snapshotBusinessTables(path: String): Map>> { diff --git a/docs/current_state.md b/docs/current_state.md index 8b80dc0..18c3303 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -50,6 +50,7 @@ parallel implementation of its rules. | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | | Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | +| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. @@ -97,6 +98,11 @@ documents the future target without implementing it. Mobile V3 still omits session link and the active Android draft. Imports are transactional per artifact, not globally across a logical publication. A receipt records desktop processing; publication alone does not prove durable peer consumption. +Characterization now also freezes the observed preservation of local-only +session/body values on an identical replay, loss of an occurrence-local note +when V3 correction reconstructs that occurrence, delete-before-send exposure, +request replay/receipt marking, and real inter-process lock contention. These +are current-behavior proofs, not lifecycle repairs. ## Android @@ -198,6 +204,17 @@ The four Android skips were the optional historical v9, v12, v13, and v14 database-fixture migration cases. Hardware MTP, real Drive, and Android instrumented tests were not part of that isolated run. +For `TRAINLOG_SYNC_CHARACTERIZATION_V1` on 2026-09-17, the isolated JDK 17 +preflight, smoke, and full modes passed. The final full run passed desktop +compilation, **75/75 normal Meson tests**, **75/75 ASan/UBSan Meson tests**, the +explicit production-path sync-gap characterization, both JSON/import contract +validators, the targeted Android draft lifecycle test, Android debug assembly, +and **204 Android tests (200 passed, 4 skipped, 0 failed/error)**. The added +Android↔desktop↔fresh-Android V3 round trip runs inside that Android total. The +four skips remain the documented optional historical v9, v12, v13, and v14 +database fixtures. No hardware MTP, real Drive, adb, instrumented device test, +user database, exchange directory, or user service participated. + The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and **62/62 ASan/UBSan Meson tests**, including the standalone public-header and Core characterization targets, plus both canonical JSON validators. diff --git a/docs/design/sync_gap_contract_v1.md b/docs/design/sync_gap_contract_v1.md index 16fc1f8..8cb05d6 100644 --- a/docs/design/sync_gap_contract_v1.md +++ b/docs/design/sync_gap_contract_v1.md @@ -433,6 +433,16 @@ HOME/XDG/tmp/exchange/bases privés par run et aucun transport réel par défaut Son test d'environnement porte la suite Android à 203 tests (199 réussis, 4 ignorés, 0 échec) sans changer les contrats de synchronisation. +**[CURRENT EVIDENCE]** `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` adds a +real Android-exporter → desktop-importer/exporter → fresh-Android-importer V3 +round trip, plus exact current-behavior assertions for identity and ordering, +identical replay versus correction, cross-artifact partial failure, +delete-before-send publication failure, request/receipt marking, and contention +on the real private-XDG lock. The assertion-level matrix is owned by +`docs/tests.md`. This evidence does not implement V4, draft transport, general +tombstones, coherent generations, causal merge, or a peer-consumption +acknowledgement. + ## 16. Sources propriétaires - état/roadmap : `docs/current_state.md`, `docs/roadmap.md` ; @@ -443,4 +453,6 @@ Son test d'environnement porte la suite Android à 203 tests (199 réussis, `android/.../TrainlogRepository.kt` ; - feedback : `docs/training_feedback.md` ; - tests : `docs/tests.md`, `tests/test_session_exchange_v3.py`, - `tests/test_sync_gap_characterization.py`, tests Android du repository. + `tests/test_sync_gap_characterization.py`, + `tests/support/roundtrip_desktop_v3.py`, Android repository tests and + `tui/tests/test_sync_body_zone_wiring.c`. diff --git a/docs/roadmap.md b/docs/roadmap.md index f17bd52..f25f454 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,16 +33,19 @@ visualizations are implemented; the other four Web routes remain placeholders. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_CHARACTERIZATION_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_DATA_LIFECYCLE_V1 ``` `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` defines the complete-sync target and its bounded dependency order. -`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` now supplies the reproducible isolated +`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` supplies the reproducible isolated desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths. -The next cursor is only `TRAINLOG_SYNC_CHARACTERIZATION_V1`; it must reuse the -existing V3 gap, draft lifecycle and harness coverage rather than duplicate it, -and it does not authorize a protocol, schema, endpoint or button change. +`TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` reuses and completes the +current-behavior evidence for cross-implementation V3 exchange, identities, +ordering, replay, partial publication, request/receipt processing and the real +lock. The next cursor is only `TRAINLOG_SYNC_DATA_LIFECYCLE_V1`; it is not +started and does not yet authorize a protocol, schema, endpoint or button +change. [Contract details](design/sync_gap_contract_v1.md). `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, @@ -119,8 +122,9 @@ it does not authorize silently implementing a page. The frozen contract orders future bounded lots as environment/test, characterization, data/lifecycles, causal deletion, generation/consumption/ack, -orchestrator/report, Web API, then button/refresh. The environment/test lot is -complete; no production synchronization implementation lot is started. +orchestrator/report, Web API, then button/refresh. The environment/test and +characterization lots are complete; no production synchronization +implementation lot is started. `WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged. ### Gym catalog V1 diff --git a/docs/tests.md b/docs/tests.md index 229849c..7abc5ea 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -668,6 +668,47 @@ artifact remains independently committed. The existing Android test proves that the active draft is absent before finalization and the completed session appears afterward. These tests do not implement the future contract. +`TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` completes the missing current- +behavior evidence without changing production code, schemas, or formats. The +coverage matrix is assertion-based; “future” rows remain criteria rather than +passing tests: + +| Current behavior | Test and production boundary | Significant assertions | Coverage | +|---|---|---|---| +| V3 omissions, new-row loss, replay resurrection, and per-artifact rollback | `tests/test_sync_gap_characterization.py` through the real desktop V3 importer/exporter | Wire fields are absent; newly imported columns are `NULL`; an identical replay preserves local-only values; a V3 correction rebuilds its occurrence and loses its local-only occurrence note; replay after deletion resurrects the session; malformed input rolls back only its artifact | Existing proof extended | +| Active draft lifecycle | `TrainlogRepositoryDraftTest.finalizeIsAtomicAndDraftNeverExportsBeforeCompletion` through the real Android repository/exporter | No draft rows export; atomic finalization removes the draft and makes the completed session exportable | Existing proof reused | +| Cross-implementation V3 round trip | `TrainlogRepositoryDraftTest.androidV3RoundTripUsesRealDesktopImporterExporterAndFreshAndroidDatabase`, with `tests/support/roundtrip_desktop_v3.py` invoking the real desktop tools | A fresh Android database reconstructs stable session/entry/exercise/equipment identities, repeated occurrences, occurrence and set order, heterogeneous sets, targets, continuous values, and explicit MAX; exact replay adds nothing | Added | +| V3 correction and forbidden rebinding | Existing desktop `session_exchange_v3` and Android repository cases | Accepted same-identity correction is reported separately from insertion; conflicting `entry_id` rebinding is rejected | Existing proof reused | +| Companion identity and revision chains | Existing aliases, equipment, BODY ZONES, causal profile-state, and feedback suites | Stable foreign references, immediate-feedback roots, immutable revisions, session follow-ups, and replay behavior remain exact | Existing proof reused | +| Inter-artifact partial failure | Existing Android inbox ordering tests and `sync_gap_characterization` | Earlier valid catalogue/components remain committed when a later session/companion is invalid; intra-artifact invalid content rolls back | Existing proof reused | +| Desktop delete-before-send publication | `sync_body_zone_wiring` through `trainlog_sync_run()` and the MTP double | The old remote catalogue is deleted, the injected send fails, the run returns an error, and no replacement exists | Added | +| Selected-invalid V3 priority | `TrainlogRepositoryDraftTest.inboxPresentMalformedV3DoesNotFallBackToValidV2` through the Android inbox importer | A present malformed V3 fails and is not masked by a valid V2 | Existing proof reused | +| Request, receipt, and processed marker | `sync_body_zone_wiring` through `trainlog_sync_run()` | Receipt and report carry the exact `request_id`; identical request replay is refused; receipt-send failure leaves the prior processed marker unchanged | Added | +| Android post-receipt consumption | Desktop request/receipt proof plus Android typed receipt/inbox component tests | Desktop processing success and Android artifact-import success are separate component results; V1 has no durable peer-consumption acknowledgement | Component-scoped only | +| Inter-process lock | `sync_body_zone_wiring` against the real `sync.lock` acquisition with private `XDG_DATA_HOME` | A pipe-coordinated child holds the lock; the second trigger returns conflict before any MTP probe; every child is joined | Added | +| V4 drafts, general tombstones, coherent generations, causal merge, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green | + +The cross-implementation helper only provisions the established minimal desktop +test schema and launches the production Python tools; it does not duplicate +their business rules. The Kotlin test uses the real Android V3 exporter and +importer on two distinct databases. Its comparison removes only the volatile +`generated_at`, canonicalizes object-key and unordered top-level collection +order, and retains occurrence/set array order as business data. + +Request/receipt evidence remains deliberately bounded: the native test proves +desktop correlation, publication, replay handling, and marking order, while +Android tests prove local receipt presentation and inbox failure behavior. A +receipt still proves desktop processing only, not durable Android consumption. +README, `architecture.md`, and `sync_exchange.md` were reviewed for this lot and +need no correction; their current ownership and V1 limitations remain accurate. + +The 2026-09-17 closing run used all three documented harness modes with the +explicit JDK 17 path. The final `full` mode passed 75/75 normal Meson tests, +75/75 ASan/UBSan Meson tests, both validators, the targeted draft lifecycle +test, Android debug assembly, and 204 Android tests (200 passed, 4 historical +fixture skips, 0 failed/error). No hardware, Drive, adb, instrumented test, or +user service was exercised. + Validated workflow: ```text diff --git a/tests/support/roundtrip_desktop_v3.py b/tests/support/roundtrip_desktop_v3.py new file mode 100644 index 0000000..189db39 --- /dev/null +++ b/tests/support/roundtrip_desktop_v3.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Bridge one Android-produced V3 artifact through the real desktop tools.""" + +import argparse +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tests")) + +from test_session_exchange_v3 import V11_SCHEMA # noqa: E402 + + +def run(arguments: list[str]) -> None: + result = subprocess.run(arguments, text=True, capture_output=True, check=False) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("android_export", type=Path) + parser.add_argument("desktop_database", type=Path) + parser.add_argument("desktop_export", type=Path) + args = parser.parse_args() + + # CONTRACT: this is the existing minimal production-compatible desktop V11 + # fixture. Business behavior remains owned by the real importer/exporter. + with sqlite3.connect(args.desktop_database) as connection: + connection.executescript(V11_SCHEMA) + + run([ + sys.executable, + str(ROOT / "tools/import_mobile_export.py"), + str(args.android_export), + "--database", + str(args.desktop_database), + ]) + run([ + sys.executable, + str(ROOT / "tools/export_pc_mobile.py"), + str(args.desktop_export), + "--database", + str(args.desktop_database), + ]) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sync_gap_characterization.py b/tests/test_sync_gap_characterization.py index 39a239c..b533c76 100644 --- a/tests/test_sync_gap_characterization.py +++ b/tests/test_sync_gap_characterization.py @@ -97,6 +97,70 @@ def main(): "WHERE observation_id='bo_v3'" ).fetchone() == (None, None) + session_row = connection.execute( + "SELECT id FROM sessions WHERE session_id='se_v3'" + ).fetchone()[0] + connection.execute( + "UPDATE sessions SET ended_at=?, notes=? WHERE id=?", + ("2026-09-09T12:59:00+02:00", "destination session note", session_row), + ) + connection.execute( + "UPDATE session_exercises SET notes='destination occurrence note' " + "WHERE entry_id='sxe_reps'" + ) + connection.execute( + "UPDATE body_observations SET session_row_id=?, notes=? " + "WHERE observation_id='bo_v3'", + (session_row, "destination body note"), + ) + + identical = run( + [sys.executable, str(IMPORTER), str(exported), "--database", str(destination)] + ) + assert "sessions_imported=0" in identical.stdout + assert "sessions_reconciled=0" in identical.stdout + assert "sessions_skipped=2" in identical.stdout + with sqlite3.connect(destination) as connection: + assert connection.execute( + "SELECT ended_at,notes FROM sessions WHERE session_id='se_v3'" + ).fetchone() == ("2026-09-09T12:59:00+02:00", "destination session note") + assert connection.execute( + "SELECT notes FROM session_exercises WHERE entry_id='sxe_reps'" + ).fetchone()[0] == "destination occurrence note" + assert connection.execute( + "SELECT session_row_id IS NOT NULL,notes FROM body_observations " + "WHERE observation_id='bo_v3'" + ).fetchone() == (1, "destination body note") + + corrected = copy.deepcopy(wire) + corrected_session = next( + item for item in corrected["sessions"] if item["session_id"] == "se_v3" + ) + corrected_occurrence = next( + item for item in corrected_session["exercises"] + if item["entry_id"] == "sxe_reps" + ) + corrected_occurrence["sets"][0]["reps"] += 1 + correction = import_document( + root, destination, corrected, "corrected-v3.json" + ) + assert "sessions_reconciled=1" in correction.stdout + assert "sessions_skipped=1" in correction.stdout + with sqlite3.connect(destination) as connection: + connection.execute("PRAGMA foreign_keys=ON") + # CONTRACT: the header/body rows are not replaced by V3, but the + # occurrence is reconstructed and its wire-absent local note is lost. + assert connection.execute( + "SELECT ended_at,notes FROM sessions WHERE session_id='se_v3'" + ).fetchone() == ("2026-09-09T12:59:00+02:00", "destination session note") + assert connection.execute( + "SELECT notes FROM session_exercises WHERE entry_id='sxe_reps'" + ).fetchone()[0] is None + assert connection.execute( + "SELECT session_row_id IS NOT NULL,notes FROM body_observations " + "WHERE observation_id='bo_v3'" + ).fetchone() == (1, "destination body note") + # CONTRACT: current snapshots have no session tombstone. Local # deletion followed by replay therefore resurrects the identity. connection.execute("DELETE FROM sessions WHERE session_id='se_v3'") diff --git a/tui/tests/test_sync_body_zone_wiring.c b/tui/tests/test_sync_body_zone_wiring.c index 031ecad..14deae2 100644 --- a/tui/tests/test_sync_body_zone_wiring.c +++ b/tui/tests/test_sync_body_zone_wiring.c @@ -4,11 +4,15 @@ * Exercises production contracts without owning runtime behavior or persistent formats. */ #include +#include #include #include #include #include +#include #include +#include +#include #include @@ -35,7 +39,19 @@ static unsigned int remote_mobile_version = 2U; static const char *remote_tracking_mode = "reps"; static bool remote_profile_available = true; static bool remote_alias_available = false; +static bool remote_request_available = false; +static bool remote_pc_catalog_available = false; +static const char *failed_send_name = NULL; +static size_t remote_delete_count = 0U; static char rclone_log[4096]; +static size_t mtp_probe_count = 0U; + +static bool write_artifacts(const char *mobile_id, const char *zone_id, + bool historical_alias_data); +static bool prepare_database(const char *case_root, bool persistent_alias, + bool initial_zone, char *database_path, + size_t database_path_size); +static bool file_contains(const char *name, const char *needle, bool expected); static bool install_fake_rclone(const char *root) { @@ -144,6 +160,7 @@ TrainlogStatus trainlog_usb_list_mtp_devices( size_t *output_count ) { + mtp_probe_count += 1U; if (output_count == NULL || output == NULL || capacity < 1U) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } @@ -155,6 +172,59 @@ TrainlogStatus trainlog_usb_list_mtp_devices( return TRAINLOG_STATUS_OK; } +static bool run_lock_contention_case(const char *case_root) +{ + TrainlogSyncReport report; + char trainlog_root[4096]; + char lock_path[4096]; + int ready[2]; + int release[2]; + pid_t child; + char signal_byte; + int status; + size_t probes_before = mtp_probe_count; + + CHECK(mkdir(case_root, 0700) == 0); + CHECK(setenv("XDG_DATA_HOME", case_root, 1) == 0); + CHECK(snprintf(trainlog_root, sizeof(trainlog_root), "%s/trainlog", + case_root) > 0); + CHECK(mkdir(trainlog_root, 0700) == 0); + CHECK(snprintf(lock_path, sizeof(lock_path), "%s/sync.lock", + trainlog_root) > 0); + CHECK(pipe(ready) == 0); + CHECK(pipe(release) == 0); + child = fork(); + CHECK(child >= 0); + if (child == 0) { + int descriptor; + (void)close(ready[0]); + (void)close(release[1]); + descriptor = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC, 0600); + if (descriptor < 0 || flock(descriptor, LOCK_EX) != 0 || + write(ready[1], "L", 1U) != 1 || + read(release[0], &signal_byte, 1U) != 1) { + _exit(EXIT_FAILURE); + } + (void)flock(descriptor, LOCK_UN); + (void)close(descriptor); + _exit(EXIT_SUCCESS); + } + (void)close(ready[1]); + (void)close(release[0]); + CHECK(read(ready[0], &signal_byte, 1U) == 1); + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true, + TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_CONFLICT); + CHECK(!report.success); + CHECK(strstr(report.error, "déjà en cours") != NULL); + CHECK(mtp_probe_count == probes_before); + CHECK(write(release[1], "R", 1U) == 1); + CHECK(close(ready[0]) == 0); + CHECK(close(release[1]) == 0); + CHECK(waitpid(child, &status, 0) == child); + CHECK(WIFEXITED(status) && WEXITSTATUS(status) == EXIT_SUCCESS); + return true; +} + TrainlogStatus trainlog_mtp_list_storages( unsigned int bus_number, unsigned int device_number, @@ -232,7 +302,9 @@ TrainlogStatus trainlog_mtp_list_folder( } if (parent_folder_id == 2U) { size_t count = 3U + (remote_profile_available ? 1U : 0U) + - (remote_alias_available ? 1U : 0U); + (remote_alias_available ? 1U : 0U) + + (remote_request_available ? 1U : 0U) + + (remote_pc_catalog_available ? 1U : 0U); size_t next = 3U; if (capacity < count) { return TRAINLOG_STATUS_INVALID_ARGUMENT; @@ -250,6 +322,16 @@ TrainlogStatus trainlog_mtp_list_folder( if (remote_alias_available) { set_entry(&output[next], 14U, 2U, "trainlog-exercise-aliases-v1.json", false); + next += 1U; + } + if (remote_request_available) { + set_entry(&output[next], 15U, 2U, + "trainlog-sync-request-v1.json", false); + next += 1U; + } + if (remote_pc_catalog_available) { + set_entry(&output[next], 16U, 2U, + "trainlog-pc-catalog-v1.json", false); } *output_count = count; return TRAINLOG_STATUS_OK; @@ -275,7 +357,8 @@ TrainlogStatus trainlog_mtp_receive_file( item_id == 11U ? "trainlog-exercise-body-zones-v1.json" : item_id == 12U ? "trainlog-equipment-associations-v2.json" : item_id == 13U ? "trainlog-exercise-profile-state-v1.json" : - item_id == 14U ? "trainlog-exercise-aliases-v1.json" : NULL; + item_id == 14U ? "trainlog-exercise-aliases-v1.json" : + item_id == 15U ? "trainlog-sync-request-v1.json" : NULL; if (name == NULL || local_path == NULL) { return TRAINLOG_STATUS_NOT_FOUND; } @@ -301,6 +384,10 @@ TrainlogStatus trainlog_mtp_send_text_file( (void)device_number; (void)storage_id; (void)parent_folder_id; + if (failed_send_name != NULL && remote_filename != NULL && + strcmp(failed_send_name, remote_filename) == 0) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } { char target[4096]; int written = snprintf(target, sizeof(target), "%s/%s", remote_root, @@ -316,18 +403,130 @@ TrainlogStatus trainlog_mtp_send_text_file( return TRAINLOG_STATUS_OK; } +static bool write_request(const char *request_id) +{ + char path[4096]; + FILE *file; + CHECK(snprintf(path, sizeof(path), "%s/trainlog-sync-request-v1.json", + remote_root) > 0); + file = fopen(path, "wb"); + CHECK(file != NULL); + CHECK(fprintf(file, + "{\"format\":\"trainlog-sync-request\",\"version\":1," + "\"request_id\":\"%s\"," + "\"requested_at\":\"2032-01-01T00:00:00+00:00\"}", request_id) > 0); + CHECK(fclose(file) == 0); + return true; +} + +static bool run_request_receipt_case(const char *case_root) +{ + static const char *const first_request = + "sr_10000000-0000-4000-8000-000000000001"; + static const char *const second_request = + "sr_10000000-0000-4000-8000-000000000002"; + TrainlogSyncReport report; + char database_path[4096]; + char last_request_path[4096]; + char last_request[128]; + FILE *file; + size_t count; + + CHECK(prepare_database(case_root, false, false, + database_path, sizeof(database_path))); + CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false)); + CHECK(write_request(first_request)); + remote_request_available = true; + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true, + TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_OK); + CHECK(report.success && report.request_present); + CHECK(strcmp(report.request_id, first_request) == 0); + CHECK(file_contains("trainlog-sync-receipt-v1.json", first_request, true)); + CHECK(snprintf(last_request_path, sizeof(last_request_path), + "%s/trainlog/sync_last_request.txt", case_root) > 0); + file = fopen(last_request_path, "rb"); + CHECK(file != NULL); + count = fread(last_request, 1U, sizeof(last_request) - 1U, file); + CHECK(ferror(file) == 0); + CHECK(fclose(file) == 0); + last_request[count] = '\0'; + CHECK(strstr(last_request, first_request) != NULL); + + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true, + TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_NOT_FOUND); + CHECK(report.request_present); + CHECK(strcmp(report.request_id, first_request) == 0); + + CHECK(write_request(second_request)); + failed_send_name = "trainlog-sync-receipt-v1.json"; + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true, + TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_SYSTEM_ERROR); + CHECK(!report.success && report.request_present); + CHECK(strcmp(report.request_id, second_request) == 0); + failed_send_name = NULL; + file = fopen(last_request_path, "rb"); + CHECK(file != NULL); + count = fread(last_request, 1U, sizeof(last_request) - 1U, file); + CHECK(ferror(file) == 0); + CHECK(fclose(file) == 0); + last_request[count] = '\0'; + CHECK(strstr(last_request, first_request) != NULL); + CHECK(strstr(last_request, second_request) == NULL); + remote_request_available = false; + return true; +} + TrainlogStatus trainlog_mtp_delete_object( unsigned int bus_number, unsigned int device_number, uint32_t item_id ) { + char path[4096]; + int written; (void)bus_number; (void)device_number; - (void)item_id; + if (item_id == 16U) { + written = snprintf(path, sizeof(path), "%s/trainlog-pc-catalog-v1.json", + remote_root); + if (written < 0 || (size_t)written >= sizeof(path) || remove(path) != 0) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + remote_pc_catalog_available = false; + remote_delete_count += 1U; + } return TRAINLOG_STATUS_OK; } +static bool run_publish_delete_failure_case(const char *case_root) +{ + TrainlogSyncReport report; + char database_path[4096]; + char catalog_path[4096]; + FILE *file; + size_t deletes_before = remote_delete_count; + + CHECK(prepare_database(case_root, false, false, + database_path, sizeof(database_path))); + CHECK(snprintf(catalog_path, sizeof(catalog_path), + "%s/trainlog-pc-catalog-v1.json", remote_root) > 0); + file = fopen(catalog_path, "wb"); + CHECK(file != NULL); + CHECK(fputs("old remote catalog", file) >= 0); + CHECK(fclose(file) == 0); + remote_pc_catalog_available = true; + failed_send_name = "trainlog-pc-catalog-v1.json"; + CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false, + TRAINLOG_SYNC_PC_TO_ANDROID, &report) == TRAINLOG_STATUS_SYSTEM_ERROR); + CHECK(!report.success); + CHECK(strstr(report.error, "publication MTP du catalogue échouée") != NULL); + CHECK(remote_delete_count == deletes_before + 1U); + CHECK(access(catalog_path, F_OK) != 0); + failed_send_name = NULL; + remote_pc_catalog_available = false; + return true; +} + static bool write_artifacts( const char *mobile_id, const char *zone_id, @@ -932,6 +1131,9 @@ static bool run_all(void) char case_m[4096]; char case_n[4096]; char case_o[4096]; + char case_p[4096]; + char case_q[4096]; + char case_r[4096]; char fake_tools[4096]; char *root = mkdtemp(temporary); @@ -954,6 +1156,9 @@ static bool run_all(void) CHECK(snprintf(case_m, sizeof(case_m), "%s/case-m", root) > 0); CHECK(snprintf(case_n, sizeof(case_n), "%s/case-n", root) > 0); CHECK(snprintf(case_o, sizeof(case_o), "%s/case-o", root) > 0); + CHECK(snprintf(case_p, sizeof(case_p), "%s/case-p", root) > 0); + CHECK(snprintf(case_q, sizeof(case_q), "%s/case-q", root) > 0); + CHECK(snprintf(case_r, sizeof(case_r), "%s/case-r", root) > 0); CHECK(snprintf(fake_tools, sizeof(fake_tools), "%s/fake-tools", root) > 0); CHECK(mkdir(fake_tools, 0700) == 0); @@ -989,6 +1194,9 @@ static bool run_all(void) /* INVARIANT: none of the three failed synchronization paths may start a * post-sync Drive upload, even when an older export remains on disk. */ CHECK(rclone_upload_count() == 14U); + CHECK(run_lock_contention_case(case_p)); + CHECK(run_request_receipt_case(case_q)); + CHECK(run_publish_delete_failure_case(case_r)); (void)puts("PASS production sync body-zone V2 proof wiring"); return true; }