trainlog/tests/test_session_exchange_v3.py

211 lines
13 KiB
Python

#!/usr/bin/env python3
"""Production V3 session exchange regression coverage."""
import copy
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools/import_mobile_export.py"
EXPORTER = ROOT / "tools/export_pc_mobile.py"
from test_mobile_import_variable_sets import SCHEMA
V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """
CREATE TABLE exercise_body_zones(
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
zone_id TEXT NOT NULL, role TEXT NOT NULL,
PRIMARY KEY(exercise_row_id,zone_id));
CREATE TABLE exercise_body_zone_sync(
exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,
synced_primary_zone_id TEXT, synced_secondary_zone_ids TEXT NOT NULL);
PRAGMA user_version=11;
"""
def run(command, ok=True):
result = subprocess.run(command, text=True, capture_output=True)
if (result.returncode == 0) != ok:
raise AssertionError(result.stdout + result.stderr)
return result
def payload():
exercises = [
{"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": "ex_duration", "name": "Duration", "recording_mode": "sets", "tracking_mode": "duration", "data_fields": 0},
{"exercise_id": "ex_walk", "name": "Walk", "recording_mode": "continuous", "tracking_mode": "duration", "data_fields": 0},
]
base = {"format": "trainlog-mobile-export", "version": 3,
"generated_at": "2026-09-09T12:00:00+02:00", "exercises": exercises,
"sessions": [], "body_observations": []}
entries = [
{"entry_id": "sxe_reps", "position": 0, "exercise_id": "ex_reps", "name": "Reps",
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
"load_mode": "external", "rest_seconds": 120, "equipment_id": "leg_press",
"target": {"sets": 3, "reps": 8, "weight_kg": 42.5},
"sets": [{"reps": 8, "weight_kg": 40.0}, {"reps": 7, "weight_kg": 0.0}]},
{"entry_id": "sxe_duration", "position": 1, "exercise_id": "ex_duration", "name": "Duration",
"recording_mode": "sets", "tracking_mode": "duration", "data_fields": 0,
"load_mode": "assistance", "rest_seconds": 60, "equipment_id": "leg_press",
"target": {"sets": 2, "duration_seconds": 45, "weight_kg": 15.0},
"sets": [{"duration_seconds": 40}]},
{"entry_id": "sxe_unweighted", "position": 2, "exercise_id": "ex_reps", "name": "Reps",
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
"load_mode": "none", "rest_seconds": 30, "equipment_id": None,
"target": {"sets": 1, "reps": 12}, "sets": [{"reps": 11}]},
{"entry_id": "sxe_walk", "position": 3, "exercise_id": "ex_walk", "name": "Walk",
"recording_mode": "continuous", "tracking_mode": "duration", "data_fields": 0,
"load_mode": "none", "rest_seconds": 0, "equipment_id": None,
"target": None, "continuous": {"duration_seconds": 600}},
]
base["sessions"] = [{"session_id": "se_v3", "started_at": "2026-09-09T12:01:00+02:00",
"session_type": "training", "exercises": entries}]
base["sessions"].append({"session_id": "se_v3_max", "started_at": "2026-09-09T13:00:00+02:00",
"session_type": "max_test", "exercises": [{
"entry_id": "sxe_max", "position": 0, "exercise_id": "ex_reps", "name": "Reps",
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
"load_mode": "none", "rest_seconds": 0, "equipment_id": "leg_press",
"target": None, "max_weight_kg": 100.0}]})
base["body_observations"] = [{"observation_id": "bo_v3", "observed_at": "2026-09-09T14:00:00+02:00",
"body_weight_kg": 72.5}]
return base
def snapshot(db):
with sqlite3.connect(db) as con:
return tuple(con.execute(f"SELECT * FROM {table} ORDER BY rowid").fetchall()
for table in ("exercises", "sessions", "session_exercises", "performed_sets",
"continuous_activity", "body_observations"))
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-v3-") as directory:
root = Path(directory); db = root / "db.sqlite"; artifact = root / "mobile-v3.json"
with sqlite3.connect(db) as con:
con.executescript(V11_SCHEMA)
artifact.write_text(json.dumps(payload()), encoding="utf-8")
first = run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)])
assert "sessions_imported=2" in first.stdout
replay = run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)])
assert "sessions_skipped=2" in replay.stdout
with sqlite3.connect(db) as con:
plans = con.execute("SELECT entry_id,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,equipment_id FROM session_exercises ORDER BY id").fetchall()
assert plans == [
("sxe_reps", "external", 120, 3, 8, None, 42.5, "leg_press"),
("sxe_duration", "assistance", 60, 2, None, 45, 15.0, "leg_press"),
("sxe_unweighted", "none", 30, 1, 12, None, None, None),
("sxe_walk", "none", 0, None, None, None, None, None),
("sxe_max", "none", 0, None, None, None, None, "leg_press"),
]
exported = root / "pc-v3.json"
run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)])
roundtrip = json.loads(exported.read_text())
assert roundtrip["version"] == 3
assert roundtrip["sessions"][0]["exercises"][0]["target"] == {"sets": 3, "reps": 8, "weight_kg": 42.5}
assert roundtrip["sessions"][0]["exercises"][2]["target"] == {"sets": 1, "reps": 12}
assert roundtrip["sessions"][0]["exercises"][3]["target"] is None
assert roundtrip["sessions"][1]["exercises"][0]["max_weight_kg"] == 100.0
legacy = copy.deepcopy(payload()); legacy["version"] = 2
for session in legacy["sessions"]:
for entry in session["exercises"]:
entry.pop("target"); entry["load_mode"] = "none"; entry["rest_seconds"] = 0
legacy_path = root / "legacy-v2.json"; legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
before = snapshot(db)
legacy_replay = run([sys.executable, str(IMPORTER), str(legacy_path), "--database", str(db)], ok=False)
assert "conflit" in (legacy_replay.stdout + legacy_replay.stderr) and snapshot(db) == before, \
legacy_replay.stdout + legacy_replay.stderr
downgrade = run([sys.executable, str(EXPORTER), str(root / "v2.json"), "--database", str(db), "--version", "2"], ok=False)
assert "export V2 avec plan interdit" in downgrade.stdout
divergent = copy.deepcopy(payload()); divergent["sessions"][0]["exercises"][0]["target"]["reps"] = 9
divergent_path = root / "divergent.json"; divergent_path.write_text(json.dumps(divergent), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)], ok=False)
assert snapshot(db) == before
mutations = [
lambda e: e["target"].update(sets=0),
lambda e: e["target"].update(reps=10001),
lambda e: e.update(rest_seconds=86401),
lambda e: e.update(load_mode="none"),
lambda e: e.update(target=None),
lambda e: e.update(position=100001),
lambda e: e["target"].update(weight_kg=float("nan")),
]
for index, mutate in enumerate(mutations):
invalid = copy.deepcopy(payload()); mutate(invalid["sessions"][0]["exercises"][0])
path = root / f"invalid-{index}.json"; path.write_text(json.dumps(invalid), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], ok=False)
assert snapshot(db) == before
# WHY: V3 validation is a pre-transaction boundary. A malformed instant
# must not leave even definitions or body rows behind.
for index, mutate in enumerate((
lambda document: document["sessions"][0].update(started_at="not-a-time"),
lambda document: document["body_observations"][0].update(observed_at="2026-02-30T12:00Z"),
lambda document: document.update(generated_at="2026-09-09 12:00:00Z"),
)):
invalid_time = copy.deepcopy(payload()); mutate(invalid_time)
path = root / f"invalid-time-{index}.json"; path.write_text(json.dumps(invalid_time), encoding="utf-8")
result = run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], ok=False)
assert "date-heure Trainlog invalide" in result.stderr and snapshot(db) == before
# The exact shared language includes omitted seconds, lowercase t/z,
# ±23:59 offsets and arbitrary fraction precision.
exact = copy.deepcopy(payload())
exact["generated_at"] = "2026-09-09t12:00z"
exact["sessions"][0]["started_at"] = "2026-09-09T12:01+23:59"
exact["sessions"][1]["started_at"] = "2026-09-09T13:00:00.123456789012345678900-23:59"
exact["body_observations"][0]["observed_at"] = "2026-09-09t14:00:00.1000z"
exact_db = root / "exact.sqlite"
with sqlite3.connect(exact_db) as con: con.executescript(V11_SCHEMA)
exact_path = root / "exact.json"; exact_path.write_text(json.dumps(exact), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(exact_path), "--database", str(exact_db)])
# Published V2 admission remains unchanged, including its historical
# nonempty timestamp rule.
legacy_time = copy.deepcopy(legacy)
legacy_time["sessions"][0]["session_id"] = "se_legacy_bad_time"
legacy_time["sessions"][0]["started_at"] = "not-a-time"
legacy_time["sessions"] = legacy_time["sessions"][:1]
legacy_time["body_observations"] = []
legacy_db = root / "legacy-time.sqlite"
with sqlite3.connect(legacy_db) as con: con.executescript(V11_SCHEMA)
legacy_time_path = root / "legacy-time.json"; legacy_time_path.write_text(json.dumps(legacy_time), encoding="utf-8")
run([sys.executable, str(IMPORTER), str(legacy_time_path), "--database", str(legacy_db)])
# INVARIANT: corrupt stored V3 instants fail before destination write.
sentinel = root / "sentinel.json"; sentinel.write_text("prior-valid-artifact", encoding="utf-8")
with sqlite3.connect(db) as con:
con.execute("UPDATE sessions SET started_at='bad-stored-time' WHERE session_id='se_v3'")
failed_export = run([sys.executable, str(EXPORTER), str(sentinel), "--database", str(db)], ok=False)
assert "started_at" in failed_export.stdout and sentinel.read_text() == "prior-valid-artifact"
with sqlite3.connect(db) as con:
con.execute("UPDATE sessions SET started_at='2026-09-09T12:01:00+02:00' WHERE session_id='se_v3'")
con.execute("UPDATE body_observations SET observed_at='bad-stored-time' WHERE observation_id='bo_v3'")
failed_export = run([sys.executable, str(EXPORTER), str(sentinel), "--database", str(db)], ok=False)
assert "observed_at" in failed_export.stdout and sentinel.read_text() == "prior-valid-artifact"
with sqlite3.connect(db) as con:
con.execute("UPDATE body_observations SET observed_at='2026-09-09T14:00:00+02:00' WHERE observation_id='bo_v3'")
duplicate = artifact.read_text().replace('"sets": 3', '"sets": 3, "sets": 4', 1)
duplicate_path = root / "duplicate.json"; duplicate_path.write_text(duplicate)
rejected = run([sys.executable, str(IMPORTER), str(duplicate_path), "--database", str(db)], ok=False)
assert "dupliqué" in rejected.stderr and snapshot(db) == before
# The same checked-in artifact is consumed by Robolectric, proving the
# wire shape rather than two independently authored platform fixtures.
shared_db = root / "shared.sqlite"
with sqlite3.connect(shared_db) as con: con.executescript(V11_SCHEMA)
shared = ROOT / "tests/fixtures/session-mobile-export-v3.json"
run([sys.executable, str(IMPORTER), str(shared), "--database", str(shared_db)])
shared_out = root / "shared-out.json"
run([sys.executable, str(EXPORTER), str(shared_out), "--database", str(shared_db)])
shared_entry = json.loads(shared_out.read_text())["sessions"][0]["exercises"][0]
assert shared_entry["target"] == {"sets": 3, "reps": 9, "weight_kg": 55.5}
assert shared_entry["sets"] == [{"reps": 9, "weight_kg": 52.5}, {"reps": 8}, {"reps": 7, "weight_kg": 0.0}]
print("PASS session_exchange_v3")
if __name__ == "__main__": main()