Files
aazerra 5c8310b837 feat: add StorageAdapter abstraction and InMemoryAdapter with full test suite
- 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
2026-08-27 16:48:15 +03:30

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