test(sync): freeze current synchronization behavior

This commit is contained in:
fy59 2026-09-17 07:16:36 +02:00
parent 9b2da4e9e8
commit 77c15172e1
9 changed files with 565 additions and 11 deletions

View file

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

View file

@ -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<String, List<List<String>>> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'")

View file

@ -4,11 +4,15 @@
* Exercises production contracts without owning runtime behavior or persistent formats.
*/
#include <stdbool.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sqlite3.h>
@ -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;
}