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
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user