#!/usr/bin/env python3 """Desktop body-zone companion replay, one-sided update and conflict regression.""" import json import sqlite3 import subprocess import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] EXPORT = ROOT / "tools/export_exercise_body_zones.py" IMPORT = ROOT / "tools/import_exercise_body_zones.py" EXERCISE_ID = "ex_11111111-1111-4111-8111-111111111111" def database(path, exercise_id=EXERCISE_ID, with_mapping=True, with_baseline=True): connection = sqlite3.connect(path) connection.executescript( """ PRAGMA foreign_keys=ON; CREATE TABLE exercises( id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE, recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL); 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 CHECK(role IN('primary','secondary')), PRIMARY KEY(exercise_row_id,zone_id)); CREATE UNIQUE INDEX exercise_body_zones_one_primary ON exercise_body_zones(exercise_row_id) WHERE role='primary'; CREATE TABLE exercise_body_zone_sync( exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE, synced_state TEXT NOT NULL); """ ) connection.execute( "INSERT INTO exercises VALUES(1,?,'Alias exercise','alias exercise','sets','reps',0)", (exercise_id,), ) if with_mapping: connection.executescript(""" INSERT INTO exercise_body_zones VALUES(1,'chest','primary'); INSERT INTO exercise_body_zones VALUES(1,'arms','secondary'); """) if with_baseline: connection.execute( "INSERT INTO exercise_body_zone_sync VALUES(1,'chest|arms')" ) connection.execute("PRAGMA user_version=11") connection.commit() connection.close() def run(script, artifact, db, *extra): result = subprocess.run( ["python3", str(script), str(artifact), "--database", str(db), *map(str, extra)], cwd=ROOT, text=True, capture_output=True, ) return result def mapping(db): connection = sqlite3.connect(db) rows = connection.execute( "SELECT zone_id,role FROM exercise_body_zones ORDER BY role,zone_id", ).fetchall() baseline = connection.execute("SELECT synced_state FROM exercise_body_zone_sync").fetchone()[0] connection.close() return rows, baseline def main(): with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary: root = Path(temporary) source = root / "source.db" target = root / "target.db" artifact = root / "trainlog-exercise-body-zones-v1.json" database(source) database(target) exported = run(EXPORT, artifact, source) assert exported.returncode == 0, exported.stdout + exported.stderr replay = run(IMPORT, artifact, target) assert replay.returncode == 0 and "zones_skipped=1" in replay.stdout, replay.stdout payload = json.loads(artifact.read_text(encoding="utf-8")) payload["exercises"][0]["primary_zone_id"] = "shoulders" artifact.write_text(json.dumps(payload), encoding="utf-8") update = run(IMPORT, artifact, target) assert update.returncode == 0 and "zones_updated=1" in update.stdout, update.stdout assert mapping(target) == ([('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms") connection = sqlite3.connect(target) connection.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=1") connection.execute("INSERT INTO exercise_body_zones VALUES(1,'back','primary')") connection.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')") connection.commit() connection.close() payload["exercises"][0]["primary_zone_id"] = "chest" artifact.write_text(json.dumps(payload), encoding="utf-8") conflict = run(IMPORT, artifact, target) assert conflict.returncode == 1 assert f"conflit zones simultané: {EXERCISE_ID}" in conflict.stdout assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms") orphan = json.loads(artifact.read_text(encoding="utf-8")) orphan["exercises"][0]["primary_zone_id"] = None orphan["exercises"][0]["secondary_zone_ids"] = ["arms"] artifact.write_text(json.dumps(orphan), encoding="utf-8") rejected_orphan = run(IMPORT, artifact, target) assert rejected_orphan.returncode == 1 assert "secondaires sans zone principale" in rejected_orphan.stdout assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms") invalid_identity = json.loads(artifact.read_text(encoding="utf-8")) invalid_identity["exercises"][0]["primary_zone_id"] = "back" invalid_identity["exercises"][0]["secondary_zone_ids"] = [] invalid_identity["exercises"][0]["exercise_id"] = "ex_not-a-uuid" artifact.write_text(json.dumps(invalid_identity), encoding="utf-8") assert run(IMPORT, artifact, target).returncode == 1 invalid_time = json.loads(json.dumps(invalid_identity)) invalid_time["exercises"][0]["exercise_id"] = EXERCISE_ID invalid_time["generated_at"] = "2032-01-01T00:00:00" artifact.write_text(json.dumps(invalid_time), encoding="utf-8") invalid_time_result = run(IMPORT, artifact, target) assert invalid_time_result.returncode == 1 assert "sans offset" in invalid_time_result.stdout corrupt_db = sqlite3.connect(target) corrupt_db.execute( "DELETE FROM exercise_body_zones WHERE role='primary'" ) corrupt_db.commit() corrupt_db.close() rejected_export = run(EXPORT, artifact, target) assert rejected_export.returncode == 1 assert "relations de zones SQLite invalides" in rejected_export.stdout corrupt_db = sqlite3.connect(target) corrupt_db.execute( "INSERT INTO exercise_body_zones VALUES(1,'back','primary')" ) corrupt_db.commit() corrupt_db.close() payload["exercises"][0]["primary_zone_id"] = None payload["exercises"][0]["secondary_zone_ids"] = [] artifact.write_text(json.dumps(payload), encoding="utf-8") connection = sqlite3.connect(source) connection.execute("DELETE FROM exercise_body_zones") connection.execute("UPDATE exercise_body_zone_sync SET synced_state='|'") connection.commit(); connection.close() no_mapping = run(IMPORT, artifact, source) assert no_mapping.returncode == 0 and "zones_skipped=1" in no_mapping.stdout # import_mobile_export may have safely coalesced a remote creator ID # into the normalized local identity before this companion arrives. # The retained source V2 definition is the required proof; name alone # is never accepted by the zone importer. alias_db = root / "alias.db" local_id = "ex_22222222-2222-4222-8222-222222222222" remote_id = "ex_33333333-3333-4333-8333-333333333333" database(alias_db, local_id, with_mapping=False) alias_artifact = root / "alias-zones.json" alias_artifact.write_text(json.dumps({ "format": "trainlog-exercise-body-zones", "version": 1, "generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{ "exercise_id": remote_id, "primary_zone_id": "back", "secondary_zone_ids": ["arms"], }], }), encoding="utf-8") proof = root / "proof.json" proof.write_text(json.dumps({ "format": "trainlog-mobile-export", "version": 2, "generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{ "exercise_id": remote_id, "name": "Alias exercise", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, }], "sessions": [], "body_observations": [], }), encoding="utf-8") alias_import = run( IMPORT, alias_artifact, alias_db, "--mobile-export", proof, ) assert alias_import.returncode == 0, alias_import.stdout + alias_import.stderr assert mapping(alias_db) == ([('back', 'primary'), ('arms', 'secondary')], "back|arms") # A custom exercise starts with no baseline on its creator. Publishing # the exact snapshot acknowledges it locally; a later peer-only edit # must then flow back instead of becoming a false simultaneous conflict. creator = root / "creator.db" peer = root / "peer.db" shared = root / "shared.json" database(creator, with_mapping=True, with_baseline=False) database(peer, with_mapping=False) assert run(EXPORT, shared, creator).returncode == 0 assert run(IMPORT, shared, creator).returncode == 0 # publication ack assert run(IMPORT, shared, peer).returncode == 0 peer_db = sqlite3.connect(peer) peer_db.execute("DELETE FROM exercise_body_zones") peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'shoulders','primary')") peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')") peer_db.commit() peer_db.close() assert run(EXPORT, shared, peer).returncode == 0 assert run(IMPORT, shared, peer).returncode == 0 # publication ack returned = run(IMPORT, shared, creator) assert returned.returncode == 0 and "zones_updated=1" in returned.stdout assert mapping(creator) == ( [('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms" ) if __name__ == "__main__": main()