utils/crdt-sync/crdt_sync/tests/test_record.py
Krzysztof kuhy Rudnicki 2005c40baa
Some checks are pending
crdt-sync pre-commit / pre-commit (push) Waiting to run
crdt-sync tests / test (3.10) (push) Waiting to run
crdt-sync tests / test (3.11) (push) Waiting to run
crdt-sync tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
Group sync_log's 11 arguments into three bundles
BREAKING: sync_log's flat keyword arguments are replaced by SyncTarget,
LogCodec and RevisionTracking. Callers change from eleven loose keywords to
four positional arguments; every consumer repo is updated in the same pass.

Eleven parameters on one function was the last real pylint finding in the
package, and the grouping is the fix rather than a workaround: the three
bundles are the three things a tick actually needs -- where to sync, how to
serialize, and whether to track revisions -- and each documents its own
fields. SyncTarget.own_ids also moves the "this device plus its pre-migration
id" rule onto the type that owns it, instead of recomputing it inline.

Internals follow: _pull_remote_logs takes a _PullContext instead of eight
positionals, and the push half splits into _merge_peers/_push, which is what
clears the 21-locals finding.

The two duplicated in-memory fakes merge into one FakeStore in conftest, as
does the no-bulk-read wrapper around it -- the shared half was the fiddly
prefix slicing, which is exactly where two copies drift.

crdt_sync now scores a clean 10.00/10 on pylint. Two suppressions remain,
both scoped and explained at the line: asserting on a private attribute IS
the timeout test, and reversing merge_record's operands IS the commutativity
test.

259 tests, still 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCucBhUkHBBHHbsY7bSdGV
2026-08-21 14:22:25 +02:00

165 lines
6.6 KiB
Python

"""Tests for Record, merge_field, and merge_record."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from crdt_sync import Hlc, Record, merge_field, merge_record
if TYPE_CHECKING:
from collections.abc import Callable
class TestMergeField:
"""Merge field."""
def test_greater_hlc_wins(self, make_hlc: Callable[..., Hlc]) -> None:
"""Greater HLC wins."""
older = ("a", make_hlc(100))
newer = ("b", make_hlc(200))
assert merge_field(older, newer) == newer
assert merge_field(newer, older) == newer
def test_equal_hlc_keeps_the_first_argument(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Equal HLC keeps the first argument."""
clock = make_hlc(100)
a = ("a", clock)
b = ("a", clock)
assert merge_field(a, b) == a
class TestMergeRecord:
"""Merge record."""
def test_raises_when_ids_differ(self) -> None:
"""Raises when ids differ."""
a = Record(id="a", fields={})
b = Record(id="b", fields={})
with pytest.raises(ValueError, match="different ids"):
merge_record(a, b)
def test_merges_disjoint_fields(self, make_hlc: Callable[..., Hlc]) -> None:
"""Merges disjoint fields."""
a = Record(id="x", fields={"text": ("hello", make_hlc(100))})
b = Record(id="x", fields={"priority": ("high", make_hlc(50))})
merged = merge_record(a, b)
assert merged.fields == {
"text": ("hello", make_hlc(100)),
"priority": ("high", make_hlc(50)),
}
def test_shared_field_keeps_the_later_write(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Shared field keeps the later write."""
a = Record(id="x", fields={"text": ("old", make_hlc(100))})
b = Record(id="x", fields={"text": ("new", make_hlc(200))})
merged = merge_record(a, b)
assert merged.fields["text"] == ("new", make_hlc(200))
def test_delete_is_sticky_against_an_older_non_deleted_copy(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Delete is sticky against an older non deleted copy."""
deleted = Record(id="x", fields={}, deleted=True, deleted_hlc=make_hlc(200))
stale = Record(id="x", fields={"text": ("resurrected", make_hlc(100))})
assert merge_record(deleted, stale).deleted is True
assert merge_record(stale, deleted).deleted is True
def test_neither_side_deleted_stays_not_deleted(self) -> None:
"""Neither side deleted stays not deleted."""
a = Record(id="x", fields={})
b = Record(id="x", fields={})
merged = merge_record(a, b)
assert merged.deleted is False
assert merged.deleted_hlc is None
def test_both_sides_deleted_keeps_the_later_delete_clock(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Both sides deleted keeps the later delete clock."""
a = Record(id="x", fields={}, deleted=True, deleted_hlc=make_hlc(100))
b = Record(id="x", fields={}, deleted=True, deleted_hlc=make_hlc(200))
merged = merge_record(a, b)
assert merged.deleted_hlc == make_hlc(200)
def test_both_deleted_but_one_side_missing_a_clock_keeps_the_other(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Both deleted but one side missing a clock keeps the other."""
a = Record(id="x", fields={}, deleted=True, deleted_hlc=None)
b = Record(id="x", fields={}, deleted=True, deleted_hlc=make_hlc(200))
assert merge_record(a, b).deleted_hlc == make_hlc(200)
# Swapping the operands is the property under test: merge_record
# must give the same answer either way round.
# pylint: disable-next=arguments-out-of-order
assert merge_record(b, a).deleted_hlc == make_hlc(200)
def test_is_commutative(self, make_hlc: Callable[..., Hlc]) -> None:
"""Is commutative."""
record_a = Record(id="x", fields={"text": ("a", make_hlc(100))})
record_b = Record(
id="x", fields={"text": ("b", make_hlc(200)), "extra": ("e", make_hlc(1))}
)
assert merge_record(record_a, record_b) == merge_record(record_b, record_a)
def test_is_idempotent(self, make_hlc: Callable[..., Hlc]) -> None:
"""Is idempotent."""
record = Record(id="x", fields={"text": ("a", make_hlc(100))})
assert merge_record(record, record) == record
class TestRecordDictRoundTrip:
"""Record dict round trip."""
def test_round_trips_a_record_with_fields(
self, make_hlc: Callable[..., Hlc]
) -> None:
"""Round trips a record with fields."""
record = Record(id="x", fields={"text": ("hello", make_hlc(100, node_id="pc"))})
assert Record.from_dict(record.to_dict()) == record
def test_round_trips_a_deleted_record(self, make_hlc: Callable[..., Hlc]) -> None:
"""Round trips a deleted record."""
record = Record(id="x", fields={}, deleted=True, deleted_hlc=make_hlc(200))
assert Record.from_dict(record.to_dict()) == record
def test_round_trips_a_record_with_no_delete_clock(self) -> None:
"""Round trips a record with no delete clock."""
record = Record(id="x", fields={}, deleted=False, deleted_hlc=None)
assert Record.from_dict(record.to_dict()) == record
class TestCrossLanguageWireFormat:
"""Pins the exact wire shape shared with crdt_sync_dart's Record.
Values chosen here are duplicated verbatim in crdt_sync_dart's
`test/record_test.dart` (see its ``TestCrossLanguageWireFormat``
group). If this test and that one both pass, the two languages agree
on the wire format; if only one changes, the two suites diverge and
at least one of them fails -- catching exactly the kind of
key-naming mismatch (``deleted_hlc`` vs ``deletedHlc``) that neither
language's own round-trip test can see on its own.
"""
def test_matches_the_fixture_shared_with_the_dart_package(self) -> None:
"""Matches the fixture shared with the dart package."""
record = Record(
id="abc123",
fields={"text": ("hello", Hlc(wall_time_ms=100, counter=0, node_id="pc"))},
deleted=True,
deleted_hlc=Hlc(wall_time_ms=1000, counter=0, node_id="node-a"),
)
expected = {
"id": "abc123",
"fields": {"text": ["hello", "1970-01-01T00:00:00.100Z-0000-pc"]},
"deleted": True,
"deleted_hlc": "1970-01-01T00:00:01.000Z-0000-node-a",
}
assert record.to_dict() == expected
assert Record.from_dict(expected) == record