trainlog/tests/test_exercise_reconciliation.py

490 lines
20 KiB
Python

#!/usr/bin/env python3
"""Exercise-identity reconciliation and complete V2 round-trip regressions."""
import copy
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORT_MOBILE = ROOT / "tools/import_mobile_export.py"
IMPORT_DEFINITIONS = ROOT / "tools/import_equipment_definitions.py"
IMPORT_ASSOCIATIONS = ROOT / "tools/import_equipment_associations.py"
EXPORT_DEFINITIONS = ROOT / "tools/export_equipment_definitions.py"
EXPORT_CATALOG = ROOT / "tools/export_pc_catalog.py"
EXPORT_MOBILE = ROOT / "tools/export_pc_mobile.py"
EXPORT_ASSOCIATIONS = ROOT / "tools/export_equipment_associations.py"
DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d"
SCHEMA = """
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,
tracking_mode TEXT NOT NULL, recording_mode TEXT NOT NULL,
data_fields INTEGER NOT NULL
);
CREATE TABLE sessions(
id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE,
started_at TEXT NOT NULL, ended_at TEXT, session_type TEXT NOT NULL, notes TEXT
);
CREATE TABLE session_exercises(
id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE,
session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, position INTEGER NOT NULL,
load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL, 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 NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,
position INTEGER NOT NULL, reps INTEGER, duration_seconds INTEGER, weight_kg REAL
);
CREATE TABLE continuous_activity(
id INTEGER PRIMARY KEY,
session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,
duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL
);
CREATE TABLE body_observations(
id INTEGER PRIMARY KEY, observation_id TEXT NOT NULL UNIQUE, observed_at TEXT NOT NULL,
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=8;
"""
def run(tool, *arguments, succeeds=True):
result = subprocess.run(
[sys.executable, str(tool), *(str(value) for value in arguments)],
text=True,
capture_output=True,
)
if succeeds:
assert result.returncode == 0, result.stdout + result.stderr
else:
assert result.returncode != 0, result.stdout + result.stderr
return result.stdout + result.stderr
def create_database(path, fields=1, with_history=False):
connection = sqlite3.connect(path)
connection.executescript(SCHEMA)
connection.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) "
"VALUES(?,?,?,?,?,?);",
(DESKTOP_WALK_ID, "Marche", "marche", "duration", "continuous", fields),
)
if with_history:
connection.execute(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);",
("se_c0d07454-b58b-403e-8b79-744619815fc5", "2026-09-07T19:27:35+02:00", "training"),
)
session_row = connection.execute("SELECT id FROM sessions").fetchone()[0]
exercise_row = connection.execute("SELECT id FROM exercises").fetchone()[0]
for position, entry_id, duration, speed, equipment in (
(0, "sxe_draft_legacy_1", 900, 6.6, None),
(8, "sxe_093c1331-beaa-4b69-91b3-240292709be6", 300, 4.0, "treadmill"),
):
cursor = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'continuous',1,?,'none',0,?);",
(entry_id, session_row, exercise_row, position, equipment),
)
connection.execute(
"INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,speed_kmh) "
"VALUES(?,?,?);",
(cursor.lastrowid, duration, speed),
)
connection.commit()
connection.close()
def mobile_payload(fields=3):
return {
"format": "trainlog-mobile-export",
"version": 2,
"generated_at": "2026-09-08T15:21:26+02:00",
"exercises": [
{
"exercise_id": ANDROID_WALK_ID,
"name": "Marche",
"recording_mode": "continuous",
"tracking_mode": "duration",
"data_fields": fields,
},
{
"exercise_id": "ex_android_strength",
"name": "Machine Android",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
},
],
"sessions": [
{
"session_id": ANDROID_SESSION_ID,
"started_at": "2026-09-08T11:25:30+02:00",
"session_type": "max_test",
"exercises": [
{
"entry_id": ANDROID_WALK_ENTRY_ID,
"position": 0,
"exercise_id": ANDROID_WALK_ID,
"name": "Marche",
"recording_mode": "continuous",
"tracking_mode": "duration",
"data_fields": fields,
"load_mode": "none",
"rest_seconds": 0,
"equipment_id": "treadmill",
"continuous": {
"duration_seconds": 900,
**({"speed_kmh": 5.5} if fields & 1 else {}),
**({"distance_km": 1.2} if fields & 2 else {}),
},
},
{
"entry_id": "sxe_android_strength",
"position": 1,
"exercise_id": "ex_android_strength",
"name": "Machine Android",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
"load_mode": "none",
"rest_seconds": 0,
"equipment_id": "eq_android_custom",
"sets": [{"reps": 9, "weight_kg": 42.5}],
},
],
},
],
"body_observations": [
{
"observation_id": "bo_android_reconciliation",
"observed_at": "2026-09-08T08:00:00+02:00",
"body_weight_kg": 83.7,
"neck_cm": 37.1,
"shoulders_cm": 111.2,
"chest_cm": 99.3,
"waist_cm": 84.4,
"hips_cm": 96.5,
"left_arm_cm": 31.6,
"right_arm_cm": 31.7,
"left_forearm_cm": 27.8,
"right_forearm_cm": 27.9,
"left_thigh_cm": 56.1,
"right_thigh_cm": 56.2,
"left_calf_cm": 37.3,
"right_calf_cm": 37.4,
},
],
}
def definitions_payload():
return {
"format": "trainlog-equipment-definitions",
"version": 1,
"generated_at": "2026-09-08T15:21:26+02:00",
"equipment": [
{
"equipment_id": "eq_android_custom",
"display_name": "Machine Android custom",
"label_name": "Machine custom",
"equipment_type": "custom_machine",
"load_semantics": "external",
},
],
}
def associations_payload():
return {
"format": "trainlog-equipment-associations",
"version": 2,
"generated_at": "2026-09-08T15:21:26+02:00",
"associations": [
{
"session_id": ANDROID_SESSION_ID,
"entry_id": ANDROID_WALK_ENTRY_ID,
"exercise_id": ANDROID_WALK_ID,
"state": "set",
"equipment_id": "treadmill",
},
{
"session_id": ANDROID_SESSION_ID,
"entry_id": "sxe_android_strength",
"exercise_id": "ex_android_strength",
"state": "set",
"equipment_id": "eq_android_custom",
},
],
}
def assert_no_integrity_error(connection):
assert connection.execute("PRAGMA integrity_check;").fetchone()[0] == "ok"
assert connection.execute("PRAGMA foreign_key_check;").fetchall() == []
def semantic_state(connection):
"""Capture exchanged business state without depending on local rowids."""
return (
connection.execute(
"SELECT exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields "
"FROM exercises ORDER BY exercise_id;"
).fetchall(),
connection.execute(
"SELECT session_id,started_at,ended_at,session_type,notes "
"FROM sessions ORDER BY session_id;"
).fetchall(),
connection.execute(
"SELECT s.session_id,se.entry_id,e.exercise_id,se.recording_mode,se.data_fields,"
"se.position,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,"
"se.target_duration_seconds,se.target_weight_kg,se.equipment_id,se.notes "
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"ORDER BY s.session_id,se.position;"
).fetchall(),
connection.execute(
"SELECT se.entry_id,ps.position,ps.reps,ps.duration_seconds,ps.weight_kg "
"FROM performed_sets ps JOIN session_exercises se "
"ON se.id=ps.session_exercise_row_id ORDER BY se.entry_id,ps.position;"
).fetchall(),
connection.execute(
"SELECT se.entry_id,ca.duration_seconds,ca.speed_kmh,ca.distance_km "
"FROM continuous_activity ca JOIN session_exercises se "
"ON se.id=ca.session_exercise_row_id ORDER BY se.entry_id;"
).fetchall(),
connection.execute(
"SELECT observation_id,observed_at,body_weight_kg,neck_cm,shoulders_cm,"
"chest_cm,waist_cm,hips_cm,left_arm_cm,right_arm_cm,left_forearm_cm,"
"right_forearm_cm,left_thigh_cm,right_thigh_cm,left_calf_cm,right_calf_cm "
"FROM body_observations ORDER BY observation_id;"
).fetchall(),
connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id;"
).fetchall(),
)
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-reconcile-") as directory:
root = Path(directory)
# A: identical profiles with different IDs use the existing desktop ID.
identical_db = root / "identical.db"
create_database(identical_db, fields=1)
identical = mobile_payload(fields=1)
identical["sessions"] = []
identical["body_observations"] = []
identical["exercises"] = identical["exercises"][:1]
identical_path = root / "identical.json"
identical_path.write_text(json.dumps(identical), encoding="utf-8")
first_identical = run(
IMPORT_MOBILE, identical_path, "--database", identical_db,
"--trace-exercises",
)
assert "exercises_reconciled=0" in first_identical
assert "exercises_skipped=1" in first_identical
assert f'"exercise_id": "{ANDROID_WALK_ID}"' in first_identical
assert f'"lookup": "normalized_name:{DESKTOP_WALK_ID}"' in first_identical
assert '"decision": "existing-identical"' in first_identical
with sqlite3.connect(identical_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises"
).fetchall() == [(DESKTOP_WALK_ID, 1)]
identical_before_replay = semantic_state(connection)
second_identical = run(
IMPORT_MOBILE, identical_path, "--database", identical_db,
"--trace-exercises",
)
assert "exercises_reconciled=0" in second_identical
assert "exercises_skipped=1" in second_identical
assert '"decision": "existing-identical"' in second_identical
with sqlite3.connect(identical_db) as connection:
assert semantic_state(connection) == identical_before_replay
# C/G: equal text cannot merge incomparable masks or different modes.
for label, mutate in (
("fields", lambda value: value["exercises"][0].update(data_fields=2)),
("mode", lambda value: value["exercises"][0].update(recording_mode="sets", tracking_mode="duration", data_fields=0)),
):
conflict_db = root / f"conflict-{label}.db"
create_database(conflict_db, fields=1)
conflict = copy.deepcopy(identical)
mutate(conflict)
conflict_path = root / f"conflict-{label}.json"
conflict_path.write_text(json.dumps(conflict), encoding="utf-8")
output = run(
IMPORT_MOBILE,
conflict_path,
"--database",
conflict_db,
succeeds=False,
)
assert "profil incompatible entre identités" in output
with sqlite3.connect(conflict_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises"
).fetchall() == [(DESKTOP_WALK_ID, 1)]
# B/D/E: run the real ordered Android -> PC tools on a v8 copy.
complete_db = root / "complete.db"
create_database(complete_db, fields=1, with_history=True)
mobile_path = root / "trainlog-mobile-export-v2.json"
definitions_path = root / "trainlog-mobile-equipment-definitions-v1.json"
associations_path = root / "trainlog-equipment-associations-v2.json"
mobile_path.write_text(json.dumps(mobile_payload()), encoding="utf-8")
definitions_path.write_text(json.dumps(definitions_payload()), encoding="utf-8")
associations_path.write_text(json.dumps(associations_payload()), encoding="utf-8")
assert "definitions_imported=1" in run(
IMPORT_DEFINITIONS, definitions_path, "--database", complete_db
)
assert "sessions_imported=1" in run(
IMPORT_MOBILE, mobile_path, "--database", complete_db
)
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in run(
IMPORT_ASSOCIATIONS,
associations_path,
"--database",
complete_db,
"--mobile-export",
mobile_path,
)
with sqlite3.connect(complete_db) as connection:
assert connection.execute(
"SELECT exercise_id,data_fields FROM exercises WHERE normalized_name='marche';"
).fetchall() == [(DESKTOP_WALK_ID, 3)]
assert connection.execute(
"SELECT COUNT(*) FROM exercises WHERE exercise_id=?;",
(ANDROID_WALK_ID,),
).fetchone()[0] == 0
rows = connection.execute(
"SELECT s.session_id,se.entry_id,se.position,e.exercise_id,se.data_fields,"
"ca.duration_seconds,ca.speed_kmh,ca.distance_km,se.equipment_id "
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id "
"WHERE e.normalized_name='marche' ORDER BY s.started_at,se.position;"
).fetchall()
assert rows == [
(
"se_c0d07454-b58b-403e-8b79-744619815fc5",
"sxe_draft_legacy_1", 0, DESKTOP_WALK_ID, 1,
900, 6.6, None, None,
),
(
"se_c0d07454-b58b-403e-8b79-744619815fc5",
"sxe_093c1331-beaa-4b69-91b3-240292709be6", 8,
DESKTOP_WALK_ID, 1, 300, 4.0, None, "treadmill",
),
(
ANDROID_SESSION_ID, ANDROID_WALK_ENTRY_ID, 0,
DESKTOP_WALK_ID, 3, 900, 5.5, 1.2, "treadmill",
),
]
assert connection.execute(
"SELECT reps,weight_kg FROM performed_sets"
).fetchall() == [(9, 42.5)]
assert connection.execute(
"SELECT body_weight_kg,neck_cm,shoulders_cm,chest_cm,waist_cm,hips_cm,"
"left_arm_cm,right_arm_cm,left_forearm_cm,right_forearm_cm,"
"left_thigh_cm,right_thigh_cm,left_calf_cm,right_calf_cm "
"FROM body_observations "
"WHERE observation_id='bo_android_reconciliation';"
).fetchone() == (
83.7, 37.1, 111.2, 99.3, 84.4, 96.5, 31.6, 31.7,
27.8, 27.9, 56.1, 56.2, 37.3, 37.4,
)
assert_no_integrity_error(connection)
# F: replay Android input, publish every PC companion, then re-import
# the merged PC snapshot. The semantic database state must stay fixed.
assert "definitions_skipped=1" in run(
IMPORT_DEFINITIONS, definitions_path, "--database", complete_db
)
replay_output = run(
IMPORT_MOBILE, mobile_path, "--database", complete_db
)
assert "sessions_skipped=1" in replay_output
assert "exercises_reconciled=0" in replay_output
assert "exercises_skipped=2" in replay_output
run(
IMPORT_ASSOCIATIONS,
associations_path,
"--database",
complete_db,
"--mobile-export",
mobile_path,
)
pc_definitions = root / "trainlog-pc-equipment-definitions-v1.json"
pc_catalog = root / "trainlog-pc-catalog-v1.json"
pc_mobile = root / "trainlog-pc-mobile-export-v2.json"
pc_associations = root / "trainlog-pc-equipment-associations-v2.json"
for tool, output in (
(EXPORT_DEFINITIONS, pc_definitions),
(EXPORT_CATALOG, pc_catalog),
(EXPORT_MOBILE, pc_mobile),
(EXPORT_ASSOCIATIONS, pc_associations),
):
run(tool, output, "--database", complete_db)
exported = json.loads(pc_mobile.read_text(encoding="utf-8"))
walk = next(
item for item in exported["exercises"]
if item["exercise_id"] == DESKTOP_WALK_ID
)
assert walk["data_fields"] == 3
old_walks = [
item
for session in exported["sessions"]
for item in session["exercises"]
if item["entry_id"] in {
"sxe_draft_legacy_1",
"sxe_093c1331-beaa-4b69-91b3-240292709be6",
}
]
assert len(old_walks) == 2
assert all(item["data_fields"] == 1 for item in old_walks)
assert all("distance_km" not in item["continuous"] for item in old_walks)
with sqlite3.connect(complete_db) as connection:
before = semantic_state(connection)
assert "sessions_skipped=2" in run(
IMPORT_MOBILE, pc_mobile, "--database", complete_db
)
run(IMPORT_ASSOCIATIONS, pc_associations, "--database", complete_db)
with sqlite3.connect(complete_db) as connection:
after = semantic_state(connection)
assert before == after
assert_no_integrity_error(connection)
print("PASS exercise_reconciliation")
if __name__ == "__main__":
main()