trainlog/tests/test_body_zone_sync.py

316 lines
14 KiB
Python

#!/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 write_companion(path, entries):
path.write_text(json.dumps({
"format": "trainlog-exercise-body-zones", "version": 1,
"generated_at": "2032-01-01T00:00:00+00:00",
"exercises": entries,
}), encoding="utf-8")
def entry(exercise_id, primary="back", secondary=None):
return {
"exercise_id": exercise_id,
"primary_zone_id": primary,
"secondary_zone_ids": ["arms"] if secondary is None else secondary,
}
def add_alias(db, source_id, canonical_id):
connection = sqlite3.connect(db)
connection.execute(
"CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY, "
"canonical_exercise_id TEXT NOT NULL)"
)
connection.execute(
"INSERT INTO exercise_aliases VALUES(?,?)", (source_id, canonical_id)
)
connection.execute("PRAGMA user_version=12")
connection.commit()
connection.close()
def assert_alias_identity(db, source_id, canonical_id):
connection = sqlite3.connect(db)
assert connection.execute(
"SELECT COUNT(*) FROM exercises WHERE exercise_id=?", (source_id,)
).fetchone()[0] == 0
assert connection.execute(
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?",
(source_id,),
).fetchone() == (canonical_id,)
assert connection.execute(
"SELECT COUNT(*) FROM exercise_body_zones"
).fetchone()[0] == 2
connection.close()
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"
write_companion(alias_artifact, [entry(remote_id)])
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")
# Explicitly retained V2 evidence coalesces source+canonical claims by
# the same exact-payload rule as a durable alias.
write_companion(alias_artifact, [entry(remote_id), entry(local_id)])
alias_import = run(
IMPORT, alias_artifact, alias_db, "--mobile-export", proof,
)
assert alias_import.returncode == 0 and "zones_updated=1" in alias_import.stdout, (
alias_import.stdout + alias_import.stderr
)
assert mapping(alias_db) == ([('back', 'primary'), ('arms', 'secondary')], "back|arms")
# Once EXERCISE_MERGE_V1 has persisted the retired creator identity,
# the companion resolves it without requiring a mobile snapshot/name
# proof. This is the normal post-merge transit order.
persistent_alias_db = root / "persistent-alias.db"
database(persistent_alias_db, local_id, with_mapping=False)
add_alias(persistent_alias_db, remote_id, local_id)
write_companion(alias_artifact, [entry(remote_id), entry(local_id)])
persistent_alias_import = run(IMPORT, alias_artifact, persistent_alias_db)
assert persistent_alias_import.returncode == 0 and \
"zones_updated=1" in persistent_alias_import.stdout, (
persistent_alias_import.stdout + persistent_alias_import.stderr
)
assert mapping(persistent_alias_db) == (
[('back', 'primary'), ('arms', 'secondary')], "back|arms"
)
assert_alias_identity(persistent_alias_db, remote_id, local_id)
persistent_replay = run(IMPORT, alias_artifact, persistent_alias_db)
assert persistent_replay.returncode == 0 and \
"zones_skipped=1" in persistent_replay.stdout, persistent_replay.stdout
assert_alias_identity(persistent_alias_db, remote_id, local_id)
# Full canonical groups must agree exactly. In particular, secondary
# claims are never unioned because that would invent direct relations.
conflict_cases = [
("complementary-secondary", entry(remote_id, "back", ["arms"]),
entry(local_id, "back", ["shoulders"])),
("primary-conflict", entry(remote_id, "back", []),
entry(local_id, "chest", [])),
("role-conflict", entry(remote_id, "back", ["arms"]),
entry(local_id, "arms", ["back"])),
("classified-unclassified", entry(remote_id, "back", []),
entry(local_id, None, [])),
]
for label, source_entry, canonical_entry in conflict_cases:
conflict_db = root / f"{label}.db"
database(conflict_db, local_id)
add_alias(conflict_db, remote_id, local_id)
before = mapping(conflict_db)
write_companion(alias_artifact, [source_entry, canonical_entry])
result = run(IMPORT, alias_artifact, conflict_db)
assert result.returncode == 1 and \
"incompatibles après résolution d'alias" in result.stdout, result.stdout
assert mapping(conflict_db) == before
assert_alias_identity(conflict_db, remote_id, local_id)
# An unresolved source cannot borrow the known canonical entry as
# identity proof, and the earlier canonical claim remains unmodified.
unknown_db = root / "unknown-group.db"
database(unknown_db, local_id)
before = mapping(unknown_db)
write_companion(alias_artifact, [
entry(local_id, "back", ["shoulders"]), entry(remote_id),
])
unknown = run(IMPORT, alias_artifact, unknown_db)
assert unknown.returncode == 1 and "exercice inconnu" in unknown.stdout
assert mapping(unknown_db) == before
# 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()