- Introduce StorageAdapter protocol and InMemoryAdapter (thread-safe, in-memory) in engine/adapter.py to decouple storage from sync logic - Refactor SyncEngine to accept a StorageAdapter, replacing the previous stub that returned an empty list - Wire server to use SyncEngine + InMemoryAdapter, removing hardcoded SYNC_OPERATIONS fixtures - Add tests for engine, adapter, generator, and types (tests/) - Add pre-commit config (.pre-commit-config.yaml) with linting/formatting hooks - Update pyproject.toml and uv.lock with new dev dependencies - Clean up type annotations and CLI help strings across generator, server, and utils
168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""Tests for engine.SyncEngine — cursor semantics and bidirectional sync."""
|
|
|
|
from engine import SyncEngine
|
|
from engine.adapter import InMemoryAdapter
|
|
from engine.types.operation import Operation
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_op(entity_id: str = "e1", device_id: str = "dev-1") -> Operation:
|
|
return Operation(
|
|
type="insert",
|
|
device_id=device_id,
|
|
timestamp="1000000.0",
|
|
entity_id=entity_id,
|
|
entity_type="obj",
|
|
payload={},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Initialisation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSyncEngineInit:
|
|
def test_default_adapter_is_in_memory(self) -> None:
|
|
engine = SyncEngine()
|
|
assert isinstance(engine.adapter, InMemoryAdapter)
|
|
|
|
def test_custom_adapter_is_stored(self) -> None:
|
|
adapter = InMemoryAdapter()
|
|
engine = SyncEngine(adapter=adapter)
|
|
assert engine.adapter is adapter
|
|
|
|
def test_none_adapter_falls_back_to_in_memory(self) -> None:
|
|
engine = SyncEngine(adapter=None)
|
|
assert isinstance(engine.adapter, InMemoryAdapter)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cursor semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCursorSemantics:
|
|
def test_fresh_sync_with_no_ops_returns_zero_cursor(self) -> None:
|
|
engine = SyncEngine()
|
|
cursor, ops = engine.sync(cursor=0, operations=[])
|
|
assert cursor == 0
|
|
assert ops == []
|
|
|
|
def test_cursor_advances_by_number_of_new_ops(self) -> None:
|
|
engine = SyncEngine()
|
|
cursor, ops = engine.sync(cursor=0, operations=[make_op(), make_op()])
|
|
assert cursor == 2
|
|
assert len(ops) == 2
|
|
|
|
def test_second_sync_at_new_cursor_returns_nothing_new(self) -> None:
|
|
engine = SyncEngine()
|
|
cursor, _ = engine.sync(cursor=0, operations=[make_op()])
|
|
cursor2, ops2 = engine.sync(cursor=cursor, operations=[])
|
|
assert cursor2 == cursor
|
|
assert ops2 == []
|
|
|
|
def test_cursor_is_stable_across_empty_syncs(self) -> None:
|
|
engine = SyncEngine()
|
|
engine.sync(cursor=0, operations=[make_op(), make_op()])
|
|
c1, _ = engine.sync(cursor=2, operations=[])
|
|
c2, _ = engine.sync(cursor=2, operations=[])
|
|
assert c1 == c2 == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bidirectional sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBidirectionalSync:
|
|
def test_client_ops_are_visible_to_next_client(self) -> None:
|
|
"""Op sent by client-1 must appear in client-2's next sync."""
|
|
engine = SyncEngine()
|
|
|
|
# client-1 sends one op
|
|
engine.sync(cursor=0, operations=[make_op(device_id="dev-1")])
|
|
|
|
# client-2 syncs from 0 — should see client-1's op
|
|
_, ops = engine.sync(cursor=0, operations=[])
|
|
assert len(ops) == 1
|
|
assert ops[0].device_id == "dev-1"
|
|
|
|
def test_client_receives_own_ops_back_on_first_sync(self) -> None:
|
|
"""Ops are appended then fetched, so the sender gets them back too."""
|
|
engine = SyncEngine()
|
|
_cursor, ops = engine.sync(cursor=0, operations=[make_op(), make_op()])
|
|
assert len(ops) == 2
|
|
|
|
def test_client_does_not_receive_already_seen_ops(self) -> None:
|
|
engine = SyncEngine()
|
|
# client-1 first sync: sends 2 ops, receives them back at cursor=2
|
|
cursor, _ = engine.sync(cursor=0, operations=[make_op(), make_op()])
|
|
|
|
# client-1 second sync: sends nothing, cursor already at 2 → empty
|
|
_, ops = engine.sync(cursor=cursor, operations=[])
|
|
assert ops == []
|
|
|
|
def test_two_clients_interleaved(self) -> None:
|
|
engine = SyncEngine()
|
|
|
|
# client-1 syncs first, sends 1 op (cursor 0→1)
|
|
c1, _ = engine.sync(cursor=0, operations=[make_op(device_id="dev-1")])
|
|
assert c1 == 1
|
|
|
|
# client-2 syncs from 0, sends 1 op (cursor 0→2, sees both)
|
|
c2, ops2 = engine.sync(cursor=0, operations=[make_op(device_id="dev-2")])
|
|
assert c2 == 2
|
|
assert len(ops2) == 2
|
|
|
|
# client-1 syncs again from 1, receives only the new op from dev-2
|
|
c1b, ops1b = engine.sync(cursor=c1, operations=[])
|
|
assert c1b == 2
|
|
assert len(ops1b) == 1
|
|
assert ops1b[0].device_id == "dev-2"
|
|
|
|
def test_server_assigned_ids_are_sequential(self) -> None:
|
|
engine = SyncEngine()
|
|
_, ops = engine.sync(cursor=0, operations=[make_op(), make_op(), make_op()])
|
|
assert [op.id for op in ops] == ["1", "2", "3"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Append-only (no conflict resolution)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppendOnly:
|
|
def test_conflicting_ops_on_same_entity_both_stored(self) -> None:
|
|
"""Both ops survive; no merging or rejection."""
|
|
engine = SyncEngine()
|
|
|
|
op1 = Operation(
|
|
type="update",
|
|
device_id="dev-1",
|
|
timestamp="1.0",
|
|
entity_id="shared",
|
|
entity_type="obj",
|
|
payload={"v": 1},
|
|
)
|
|
op2 = Operation(
|
|
type="update",
|
|
device_id="dev-2",
|
|
timestamp="2.0",
|
|
entity_id="shared",
|
|
entity_type="obj",
|
|
payload={"v": 2},
|
|
)
|
|
|
|
engine.sync(cursor=0, operations=[op1])
|
|
engine.sync(cursor=0, operations=[op2])
|
|
|
|
_, all_ops = engine.sync(cursor=0, operations=[])
|
|
assert len(all_ops) == 2
|
|
payloads = [op.payload for op in all_ops]
|
|
assert {"v": 1} in payloads
|
|
assert {"v": 2} in payloads
|