test(sync): close lifecycle validation evidence

This commit is contained in:
fy59 2026-09-17 09:24:23 +02:00
parent 3db84bbfb0
commit 4b8f5b02b8
7 changed files with 295 additions and 13 deletions

View file

@ -10,6 +10,14 @@
remains V3; no service, user database, phone, tag or release artifact was
changed or deployed.
- Closed the lifecycle validation evidence with real Android/desktop V4
history and execution-draft round trips in both producer directions. Fixed
Android V4 import so validated target, load, and rest fields are persisted,
reconciled the 73/75 native count to the two `web=auto` frontend tests, and
documented identical pre-existing GCC 16 `-Wmisleading-indentation`
failures on baseline and branch while retaining Clang 22 strict green
builds. V4 remains staged and inactive.
- 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

View file

@ -4072,7 +4072,7 @@ class TrainlogRepository(
put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId)
put("position", position); put("recording_mode", recording); put("tracking_mode", tracking)
put("data_fields", entry.optInt("data_fields", 0))
if (version == 3) {
if (version >= 3) {
val target = entry.optJSONObject("target")
put("load_mode", entry.getString("load_mode"))
put("rest_seconds", entry.getInt("rest_seconds"))

View file

@ -38,6 +38,8 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@ -507,6 +509,146 @@ class TrainlogRepositoryDraftTest {
}
}
@Test
fun v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations() {
val sourceName = "v4-source-${UUID.randomUUID()}.db"
val destinationName = "v4-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-v4-roundtrip-").toFile()
try {
val exercise = createExercise(source, "V4 échange", RecordingMode.SETS, TrackingMode.REPS)
val entryId = "sxe_40000000-0000-4000-8000-000000000001"
assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft(
entryId = entryId, exercise = exercise, equipmentId = "leg_press",
plan = SessionExercisePlan(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)),
)))) is SaveSessionResult.Saved)
val sourceArtifact = JSONObject(source.buildMobileExportV4Json())
val session = sourceArtifact.getJSONArray("sessions").getJSONObject(0)
val sessionId = session.getString("session_id")
assertTrue(source.updateSyncNote("session", sessionId,
"quoted \"note\" \\ newline\né") is SyncNoteEditResult.Saved)
assertTrue(source.updateSyncNote("occurrence", entryId, "x".repeat(4096))
is SyncNoteEditResult.Saved)
assertEquals(SyncNoteEditResult.Invalid,
source.updateSyncNote("occurrence", entryId, "x".repeat(4097)))
val observation = source.saveBodyObservation(
BodyObservationDraft(bodyWeightKg = 81.25, waistCm = 84.0),
) as SaveBodyObservationResult.Saved
assertTrue(source.linkBodyObservationToSession(observation.observationId, sessionId))
assertTrue(source.updateSyncNote("observation", observation.observationId, "")
is SyncNoteEditResult.Saved)
val androidExport = File(root, "android-v4.json")
val completed = JSONObject(source.buildMobileExportV4Json())
val completedSession = completed.getJSONArray("sessions").getJSONObject(0)
val started = OffsetDateTime.parse(completedSession.getString("started_at"))
completedSession.put("ended_at", started.plusMinutes(1).toString())
androidExport.writeText(completed.toString())
val desktopDatabase = File(root, "desktop.sqlite")
val desktopExport = File(root, "desktop-v4.json")
runLifecycleBridge("history", androidExport, desktopDatabase, desktopExport)
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)
val equalEnd = JSONObject(returned.toString())
equalEnd.getJSONArray("sessions").getJSONObject(0)
.put("ended_at", started.toString())
assertTrue(destination.applyPcMobileExportV4Json(equalEnd.toString())
is MobileSessionImportResult.Invalid)
val equivalentOffset = JSONObject(returned.toString())
equivalentOffset.getJSONArray("sessions").getJSONObject(0)
.put("ended_at", started.withOffsetSameInstant(ZoneOffset.UTC).toString())
assertTrue(destination.applyPcMobileExportV4Json(equivalentOffset.toString())
is MobileSessionImportResult.Invalid)
assertEquals(MobileSessionImportResult.Applied(1, 0, 1, 0),
destination.applyPcMobileExportV4Json(returned.toString()))
assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 1),
destination.applyPcMobileExportV4Json(returned.toString()))
val detail = destination.getSessionDetail(sessionId)!!.exercises.single()
assertEquals(entryId, detail.entryId)
assertEquals(90, detail.plan!!.restSeconds)
assertEquals(listOf(8, 6), detail.sets.map { it.reps })
assertEquals(listOf(40.0, 42.5), detail.sets.map { it.weightKg })
assertEquals(sessionId, JSONObject(destination.buildMobileExportV4Json())
.getJSONArray("body_observations").getJSONObject(0).getString("session_id"))
assertEquals(semanticV4Projection(JSONObject(androidExport.readText())),
semanticV4Projection(JSONObject(destination.buildMobileExportV4Json())))
val secondDesktop = File(root, "desktop-fresh.sqlite")
val finalDesktopExport = File(root, "desktop-final-v4.json")
val destinationExport = File(root, "destination-v4.json")
destinationExport.writeText(destination.buildMobileExportV4Json())
runLifecycleBridge("history", destinationExport, secondDesktop, finalDesktopExport)
assertEquals(semanticV4Projection(JSONObject(destinationExport.readText())),
semanticV4Projection(JSONObject(finalDesktopExport.readText())))
} finally {
source.close(); destination.close()
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations() {
val sourceName = "draft-source-${UUID.randomUUID()}.db"
val destinationName = "draft-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
val root = Files.createTempDirectory("trainlog-draft-roundtrip-").toFile()
try {
val exercise = createExercise(source, "Draft bridge", RecordingMode.SETS, TrackingMode.REPS)
val entryId = "sxe_50000000-0000-4000-8000-000000000001"
assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(SessionExerciseDraft(
entryId = entryId, exercise = exercise,
plan = SessionExercisePlan(3, reps = 7, restSeconds = 75),
sets = listOf(SessionSetDraft(reps = 7)),
)), form = SessionDraftForm(weightText = "unfinished 42.")),
))
val initial = JSONObject(source.buildExecutionDraftExportV1Json())
assertFalse(initial.toString().contains("unfinished 42."))
val draft = initial.getJSONArray("drafts").getJSONObject(0)
val sessionId = draft.getString("session_id")
val androidExport = File(root, "android-draft.json").apply { writeText(initial.toString()) }
val desktopDatabase = File(root, "desktop.sqlite")
val desktopExport = File(root, "desktop-draft.json")
runLifecycleBridge("draft", androidExport, desktopDatabase, desktopExport)
val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1)
.put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises"))
assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
assertEquals(ExecutionDraftImportResult.Applied(1, 0, 0, 0),
destination.applyExecutionDraftExportV1Json(desktopExport.readText()))
assertEquals(ExecutionDraftImportResult.Applied(0, 0, 1, 0),
destination.applyExecutionDraftExportV1Json(desktopExport.readText()))
val destinationArtifact = File(root, "destination-draft.json").apply {
writeText(destination.buildExecutionDraftExportV1Json())
}
val freshDesktop = File(root, "desktop-fresh.sqlite")
val finalArtifact = File(root, "desktop-final-draft.json")
runLifecycleBridge("draft", destinationArtifact, freshDesktop, finalArtifact)
assertEquals(semanticDraftProjection(JSONObject(androidExport.readText())),
semanticDraftProjection(JSONObject(finalArtifact.readText())))
assertEquals(FinalizeActiveDraftResult.Saved(sessionId),
destination.finalizeExecutionDraft(sessionId))
destination.close()
val reopened = TrainlogRepository(context, destinationName)
assertEquals(ExecutionDraftImportResult.Applied(0, 0, 0, 1),
reopened.applyExecutionDraftExportV1Json(desktopExport.readText()))
reopened.close()
} finally {
source.close()
try { destination.close() } catch (_: Exception) { }
context.deleteDatabase(sourceName); context.deleteDatabase(destinationName)
root.deleteRecursively()
}
}
@Test
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
val repo = openRepository()
@ -2583,6 +2725,34 @@ class TrainlogRepositoryDraftTest {
return canonicalJson(copy).toString()
}
private fun semanticV4Projection(value: JSONObject): String =
semanticV3Projection(value)
private fun semanticDraftProjection(value: JSONObject): String {
val copy = JSONObject(value.toString())
copy.remove("generated_at")
val source = copy.getJSONArray("drafts")
val sorted = (0 until source.length()).map { source.getJSONObject(it) }
.sortedBy { it.getString("session_id") }
copy.put("drafts", JSONArray().also { output -> sorted.forEach(output::put) })
return canonicalJson(copy).toString()
}
private fun runLifecycleBridge(
kind: String,
input: File,
database: File,
output: File,
) {
val bridge = findRepositoryFile("tests/support/roundtrip_desktop_lifecycle.py")
val process = ProcessBuilder(
"python3", bridge.absolutePath, kind, input.absolutePath,
database.absolutePath, output.absolutePath,
).redirectErrorStream(true).start()
val text = process.inputStream.bufferedReader().readText()
assertEquals("desktop lifecycle bridge failed: $text", 0, process.waitFor())
}
private fun canonicalJson(value: Any?): Any = when (value) {
is JSONObject -> JSONObject().also { normalized ->
value.keys().asSequence().toList().sorted().forEach { key ->

View file

@ -223,6 +223,17 @@ 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.
For the `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` closeout on 2026-09-17, real staged
V4 history and execution-draft entry points completed both Android/desktop
producer directions through fresh destination databases. The closeout fixed
one Android V4 import defect that validated but failed to persist occurrence
plan fields. Isolated validation passed **75/75 normal Meson tests**,
**75/75 ASan/UBSan Meson tests**, Android debug assembly, and **207 Android
tests (203 passed, 4 skipped, 0 failed/error)**. The four skips remain the
optional real historical v9, v12, v13, and v14 database fixtures. The active
transport remains V3; no phone, MTP, Drive, service, user database, deployment,
or instrumented device test 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

@ -443,6 +443,14 @@ on the real private-XDG lock. The assertion-level matrix is owned by
tombstones, coherent generations, causal merge, or a peer-consumption
acknowledgement.
**[CURRENT EVIDENCE]** `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` now has
real cross-implementation round trips for enriched mobile V4 history and the
separate execution-draft V1 artifact in both producer directions. The exact
entry points, assertions, commands, compiler comparison, test inventory, and
remaining transport boundary are recorded in `docs/tests.md`. This evidence
does not select V4 transport and does not implement causal deletion,
generation manifests, peer-consumption acknowledgement, or orchestration.
## 16. Sources propriétaires
- état/roadmap : `docs/current_state.md`, `docs/roadmap.md` ;

View file

@ -402,11 +402,27 @@ python3 tools/validate_sync_isolated.py --suite full --jdk /usr/lib/jvm/java-17-
`TRAINLOG_SYNC_DATA_LIFECYCLE_V1` adds schema v19/v18 migration assertions and
Android lifecycle coverage for confirmed-only serialization, persistent
pending state, occupied-singleton refusal, explicit activation, identity-
preserving idempotent finalization and stale replay. The full isolated run at
closure passed 73/73 normal native tests, 73/73 ASan/UBSan native tests and 205
Android tests (201 passed, four unchanged historical-fixture skips), plus both
Python validators and `assembleDebug`. These are artifact/repository proofs;
they do not claim transport activation or global synchronization atomicity.
preserving idempotent finalization and stale replay. The closeout adds
`v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` and
`executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations`. Their
bridge provisions only synthetic schema and invokes the real Android and
desktop staged entry points; it does not reconstruct wire data. The final
isolated run passed 75/75 normal native tests, 75/75 ASan/UBSan native tests
and 207 Android tests (203 passed, four unchanged historical-fixture skips),
plus both Python validators and `assembleDebug`. These are artifact/repository
proofs; they do not claim transport activation or global synchronization
atomicity.
The 75-versus-73 inventory contains no removed, renamed, merged, or disabled
native tests. With `web=auto` and no `web/node_modules`, Meson selects 73 tests
and omits exactly `web_frontend_typecheck` and `web_frontend`. After `npm ci`
from the locked package file and Meson reconfiguration, both the normal and
ASan/UBSan selections contain and pass all 75. Clang 22.1.8 is the compiler
used for those green builds. GCC 16.2.1 fails both baseline `77c1517` and the
lifecycle branch under the unchanged strict `-Werror` policy on
`-Wmisleading-indentation` in unchanged `json_writer.c`,
`web_dashboard_json.c`, and `web_dashboard.c`; this is a pre-existing
compiler-version limitation, not lifecycle evidence and not a relaxed gate.
Use `--jdk /absolute/jdk17/home` when auto-detection is unsuitable and
`--run-parent /absolute/private/parent` to select another parent outside the
@ -695,14 +711,16 @@ passing tests:
| 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 |
| Enriched V4 completed history | `TrainlogRepositoryDraftTest.v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android V4 repository methods and the real desktop V4 importer/exporter | Android → desktop → fresh Android and desktop → Android → fresh desktop preserve stable identities, order, heterogeneous sets, plans, equipment, causal null/empty/escaped UTF-8 notes and idempotent replay | Added cross-implementation proof |
| Execution-draft V1 | `TrainlogRepositoryDraftTest.executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android repository methods and `execution_draft_exchange.py` | Both implementation directions preserve stable session/entry identity and confirmed facts; raw unfinished form text remains local; replay is unchanged and pre-finalization replay is stale after reopen/finalization | Added cross-implementation proof |
| General tombstones, coherent generations, causal deletion, 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.
The cross-implementation helpers only provision minimal production-compatible
desktop schemas and launch production Python tools; they do not duplicate
their business rules. Kotlin tests use real Android exporters and importers on
distinct databases. Comparisons remove only volatile `generated_at`,
canonicalize object-key and unordered top-level collection order, and retain
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

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Bridge lifecycle artifacts through the real staged desktop entry points."""
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
V19_ADDITIONS = """
ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT;
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT,CHECK(source_exercise_id<>canonical_exercise_id));
CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL);
CREATE TABLE sync_note_revisions(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),PRIMARY KEY(owner_kind,owner_id,revision_id));
CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id),FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id));
CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL);
CREATE TABLE execution_draft_revisions(session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL,PRIMARY KEY(session_id,revision_id));
CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL);
PRAGMA user_version=19;
"""
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 create_v19(path: Path) -> None:
# CONTRACT: fixture provisioning supplies only the production schema. All
# artifact semantics remain owned by the real desktop tools below.
with sqlite3.connect(path) as connection:
connection.executescript(V11_SCHEMA)
connection.executescript(V19_ADDITIONS)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("kind", choices=("history", "draft"))
parser.add_argument("input", type=Path)
parser.add_argument("database", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
create_v19(args.database)
if args.kind == "history":
run([sys.executable, str(ROOT / "tools/import_mobile_export.py"),
str(args.input), "--database", str(args.database)])
run([sys.executable, str(ROOT / "tools/export_pc_mobile.py"),
str(args.output), "--database", str(args.database), "--version", "4"])
else:
tool = str(ROOT / "tools/execution_draft_exchange.py")
run([sys.executable, tool, "import", str(args.input),
"--database", str(args.database)])
run([sys.executable, tool, "export", str(args.output),
"--database", str(args.database)])
if __name__ == "__main__":
main()