test(sync): freeze current synchronization behavior
This commit is contained in:
parent
9b2da4e9e8
commit
77c15172e1
9 changed files with 565 additions and 11 deletions
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
|
|
@ -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>>> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
tests/support/roundtrip_desktop_v3.py
Normal file
52
tests/support/roundtrip_desktop_v3.py
Normal 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()
|
||||
|
|
@ -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'")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue