"""Sync between two user-owned nodes: convergence, idempotence, and deterministic merging of concurrent operations.""" from fablepool import sync from helpers import DATASETS, all_op_ids, get_all_claims def _prepare(phone, laptop): phone.ingest_calendar(DATASETS / "phone" / "calendar.ics") phone.ingest_photos(DATASETS / "phone" / "photos.json") phone.derive() laptop.ingest_notes(DATASETS / "laptop" / "notes") laptop.derive() def _converge(a, b): # Run both directions so the test holds whether sync.sync is one-way # pull or already bidirectional. sync.sync(a, b) sync.sync(b, a) def test_two_node_convergence(phone, laptop): _prepare(phone, laptop) assert all_op_ids(phone) != all_op_ids(laptop), ( "nodes should diverge before syncing" ) _converge(phone, laptop) assert all_op_ids(phone), "converged log must not be empty" assert all_op_ids(phone) == all_op_ids(laptop) def test_claims_visible_on_both_after_sync(phone, laptop): _prepare(phone, laptop) _converge(phone, laptop) phone_claims = {c["id"] for c in get_all_claims(phone)} laptop_claims = {c["id"] for c in get_all_claims(laptop)} assert phone_claims, "claims derived on either node must be visible" assert phone_claims == laptop_claims def test_sync_is_idempotent(phone, laptop): _prepare(phone, laptop) _converge(phone, laptop) before_phone = all_op_ids(phone) before_laptop = all_op_ids(laptop) _converge(phone, laptop) assert all_op_ids(phone) == before_phone assert all_op_ids(laptop) == before_laptop def test_concurrent_refutations_merge_deterministically(phone, laptop): _prepare(phone, laptop) _converge(phone, laptop) claims = sorted(get_all_claims(phone), key=lambda c: c["id"]) assert len(claims) >= 2, "need at least two claims for a concurrent edit" claim_a = claims[0]["id"] claim_b = claims[-1]["id"] # Concurrent, offline edits on different devices. phone.refute(claim_a, reason="phone disagrees") laptop.refute(claim_b, reason="laptop disagrees") _converge(phone, laptop) assert all_op_ids(phone) == all_op_ids(laptop), ( "both refutations must be retained after merge" ) phone_status = { c["id"]: c.get("status", "active") for c in get_all_claims(phone) } laptop_status = { c["id"]: c.get("status", "active") for c in get_all_claims(laptop) } assert phone_status == laptop_status, ( "both nodes must compute the same post-merge claim state" ) assert phone_status.get(claim_a, "absent") != "active" assert phone_status.get(claim_b, "absent") != "active" def test_sync_preserves_divergent_evidence(phone, laptop): _prepare(phone, laptop) phone_only = all_op_ids(phone) laptop_only = all_op_ids(laptop) _converge(phone, laptop) merged = all_op_ids(phone) assert phone_only <= merged assert laptop_only <= merged