#!/usr/bin/env python3 """Explicit max V2 import/export and idempotence regression.""" 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" SCHEMA = """ CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER); CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT); CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position)); CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL); CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL); CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY,max_weight_kg REAL NOT NULL CHECK(max_weight_kg>0)); CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT); CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY,display_name TEXT NOT NULL,label_name TEXT NOT NULL,equipment_type TEXT NOT NULL,load_semantics TEXT NOT NULL); PRAGMA user_version=10; """ def payload(): catalog = [ {"exercise_id": "ex_pec", "name": "Pec Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, {"exercise_id": "ex_rear", "name": "Rear Delt Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, ] entries = [ {"entry_id": "sxe_pec", "position": 0, "exercise_id": "ex_pec", "name": "Pec Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, "load_mode": "none", "rest_seconds": 0, "equipment_id": "rear_delt_pec_fly", "max_weight_kg": 100.0}, {"entry_id": "sxe_rear", "position": 1, "exercise_id": "ex_rear", "name": "Rear Delt Fly", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, "load_mode": "none", "rest_seconds": 0, "equipment_id": "rear_delt_pec_fly", "max_weight_kg": 86.0}, ] return { "format": "trainlog-mobile-export", "version": 2, "generated_at": "2031-02-03T09:00:00+01:00", "exercises": catalog, "sessions": [{"session_id": "se_max", "started_at": "2031-02-03T08:15:00+01:00", "session_type": "max_test", "exercises": entries}], "body_observations": [], } def run(*arguments): return subprocess.run( [sys.executable, *map(str, arguments)], text=True, capture_output=True, ) def main(): with tempfile.TemporaryDirectory(prefix="trainlog-max-sync-") as temp: root = Path(temp) database = root / "trainlog.db" artifact = root / "mobile-v2.json" exported = root / "pc-v2.json" connection = sqlite3.connect(database) connection.executescript(SCHEMA) connection.close() artifact.write_text(json.dumps(payload()), encoding="utf-8") first = run(IMPORTER, artifact, "--database", database) assert first.returncode == 0, first.stdout + first.stderr assert "sessions_imported=1" in first.stdout connection = sqlite3.connect(database) rows = connection.execute( "SELECT e.exercise_id,se.entry_id,se.equipment_id,mr.max_weight_kg " "FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id " "JOIN exercises e ON e.id=se.exercise_row_id ORDER BY se.position" ).fetchall() assert rows == [ ("ex_pec", "sxe_pec", "rear_delt_pec_fly", 100.0), ("ex_rear", "sxe_rear", "rear_delt_pec_fly", 86.0), ] assert connection.execute("SELECT count(*) FROM performed_sets").fetchone()[0] == 0 connection.close() second = run(IMPORTER, artifact, "--database", database) assert second.returncode == 0, second.stdout + second.stderr assert "sessions_skipped=1" in second.stdout renamed = payload() renamed["exercises"][1]["name"] = "Rear Delt" renamed["sessions"][0]["exercises"][1]["name"] = "Rear Delt" artifact.write_text(json.dumps(renamed), encoding="utf-8") rename_result = run(IMPORTER, artifact, "--database", database) assert rename_result.returncode == 0, rename_result.stdout + rename_result.stderr assert "exercises_reconciled=1" in rename_result.stdout assert "sessions_skipped=1" in rename_result.stdout connection = sqlite3.connect(database) assert connection.execute( "SELECT exercise_id,name FROM exercises WHERE exercise_id='ex_rear'" ).fetchone() == ("ex_rear", "Rear Delt") assert connection.execute("SELECT count(*) FROM exercises").fetchone()[0] == 2 assert connection.execute("SELECT count(*) FROM sessions").fetchone()[0] == 1 assert connection.execute( "SELECT se.entry_id,se.equipment_id,mr.max_weight_kg " "FROM max_results mr JOIN session_exercises se " "ON se.id=mr.session_exercise_row_id ORDER BY se.position" ).fetchall() == [ ("sxe_pec", "rear_delt_pec_fly", 100.0), ("sxe_rear", "rear_delt_pec_fly", 86.0), ] connection.close() rename_replay = run(IMPORTER, artifact, "--database", database) assert rename_replay.returncode == 0, rename_replay.stdout + rename_replay.stderr assert "sessions_skipped=1" in rename_replay.stdout resumed = copy.deepcopy(renamed) resumed["sessions"][0]["exercises"][0]["max_weight_kg"] = 101.5 artifact.write_text(json.dumps(resumed), encoding="utf-8") update = run(IMPORTER, artifact, "--database", database) assert update.returncode == 0, update.stdout + update.stderr assert "sessions_reconciled=1" in update.stdout assert sqlite3.connect(database).execute( "SELECT max_weight_kg FROM max_results mr JOIN session_exercises se " "ON se.id=mr.session_exercise_row_id WHERE se.entry_id='sxe_pec'" ).fetchone() == (101.5,) result = run(EXPORTER, exported, "--database", database) assert result.returncode == 0, result.stdout + result.stderr round_trip = json.loads(exported.read_text(encoding="utf-8")) values = round_trip["sessions"][0]["exercises"] assert [entry["max_weight_kg"] for entry in values] == [101.5, 86.0] assert values[1]["exercise_id"] == "ex_rear" assert values[1]["name"] == "Rear Delt" assert all("sets" not in entry and "continuous" not in entry for entry in values) invalid = copy.deepcopy(payload()) invalid["sessions"][0]["session_type"] = "training" rejected_path = root / "invalid.json" rejected_path.write_text(json.dumps(invalid), encoding="utf-8") rejected = run(IMPORTER, rejected_path, "--database", database) assert rejected.returncode != 0 assert "max_weight_kg exige" in rejected.stderr assert sqlite3.connect(database).execute("SELECT count(*) FROM sessions").fetchone()[0] == 1 print("max sync: PASS") if __name__ == "__main__": main()