mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
Some checks are pending
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
272 lines
7.5 KiB
Python
272 lines
7.5 KiB
Python
"""Tests for the pull/merge/push sync orchestration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock
|
|
|
|
from crdt_sync import (
|
|
GitHubSyncClient,
|
|
LogCodec,
|
|
Record,
|
|
SyncTarget,
|
|
sync_log,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
import pytest
|
|
|
|
from crdt_sync import Hlc, Log
|
|
|
|
|
|
def _encode(log: Log) -> str:
|
|
return json.dumps(
|
|
{record_id: record.to_dict() for record_id, record in log.items()}
|
|
)
|
|
|
|
|
|
def _decode(text: str) -> Log:
|
|
return {
|
|
record_id: Record.from_dict(data)
|
|
for record_id, data in json.loads(text).items()
|
|
}
|
|
|
|
|
|
def _mock_client(
|
|
*,
|
|
devices: tuple[str, ...] = (),
|
|
files: dict[str, str] | None = None,
|
|
) -> MagicMock:
|
|
"""Build a mock ``GitHubSyncClient`` covering the methods sync_log calls."""
|
|
client = MagicMock(spec=GitHubSyncClient)
|
|
client.list_directory.return_value = list(devices)
|
|
resolved_files = files or {}
|
|
client.get_file_text.side_effect = resolved_files.get
|
|
return client
|
|
|
|
|
|
class TestSyncLog:
|
|
"""Sync log."""
|
|
|
|
def test_pushes_local_log_when_no_other_devices_have_synced(
|
|
self, make_hlc: Callable[..., Hlc]
|
|
) -> None:
|
|
"""Pushes local log when no other devices have synced."""
|
|
local_log = {"a": Record(id="a", fields={"text": ("hello", make_hlc(100))})}
|
|
client = _mock_client()
|
|
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
local_log,
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
assert merged == local_log
|
|
client.put_file_text.assert_called_once()
|
|
assert client.put_file_text.call_args.args[0] == "devices/pc/log.json"
|
|
|
|
def test_skips_its_own_device_id_when_listing(self) -> None:
|
|
"""Skips its own device id when listing."""
|
|
client = _mock_client(
|
|
devices=("pc", "phone"), files={"devices/phone/log.json": "{}"}
|
|
)
|
|
|
|
sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
client.get_file_text.assert_called_once_with("devices/phone/log.json")
|
|
|
|
def test_skips_its_legacy_device_id_as_well_as_its_current_one(self) -> None:
|
|
"""A migrated device must not re-merge its own pre-migration log.
|
|
|
|
Without this the old path looks like a peer, so the device pulls
|
|
back everything it pushed under its former id every single tick.
|
|
"""
|
|
client = _mock_client(
|
|
devices=("pc", "new-uuid", "phone"),
|
|
files={"devices/phone/log.json": "{}"},
|
|
)
|
|
|
|
sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="new-uuid",
|
|
legacy_device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
client.get_file_text.assert_called_once_with("devices/phone/log.json")
|
|
|
|
def test_pulls_the_old_path_when_no_legacy_id_is_declared(self) -> None:
|
|
"""Sanity check the previous test: absent the legacy id, it *is* pulled."""
|
|
client = _mock_client(
|
|
devices=("pc", "new-uuid"), files={"devices/pc/log.json": "{}"}
|
|
)
|
|
|
|
sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="new-uuid",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
client.get_file_text.assert_called_once_with("devices/pc/log.json")
|
|
|
|
def test_skips_a_device_with_no_pushed_file_yet(self) -> None:
|
|
"""Skips a device with no pushed file yet."""
|
|
client = _mock_client(devices=("phone",), files={})
|
|
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
assert not merged
|
|
|
|
def test_skips_a_device_whose_pushed_file_is_corrupt(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""An interrupted/truncated push must not crash every other device's
|
|
merge -- it is treated the same as a device that hasn't pushed yet.
|
|
"""
|
|
client = _mock_client(
|
|
devices=("phone",),
|
|
files={"devices/phone/log.json": "{not valid json"},
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
assert not merged
|
|
assert "Unparsable log" in caplog.text
|
|
|
|
def test_skips_a_device_whose_pushed_json_has_the_wrong_shape(self) -> None:
|
|
"""Valid JSON that isn't a record map (e.g. from an incompatible
|
|
writer) must be skipped like corrupt JSON, not crash the whole
|
|
sync -- this is what the broad ``(ValueError, KeyError, TypeError)``
|
|
catch in ``_pull_remote_logs`` is for, not just JSON syntax errors.
|
|
"""
|
|
client = _mock_client(
|
|
devices=("phone",),
|
|
files={"devices/phone/log.json": '{"a": 5}'},
|
|
)
|
|
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
assert not merged
|
|
|
|
def test_merges_in_a_remote_devices_entries(
|
|
self, make_hlc: Callable[..., Hlc]
|
|
) -> None:
|
|
"""Merges in a remote devices entries."""
|
|
remote_log = {
|
|
"b": Record(id="b", fields={"text": ("from phone", make_hlc(100))})
|
|
}
|
|
client = _mock_client(
|
|
devices=("phone",),
|
|
files={"devices/phone/log.json": _encode(remote_log)},
|
|
)
|
|
|
|
merged = sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
decode=_decode,
|
|
encode=_encode,
|
|
),
|
|
)
|
|
|
|
assert merged == remote_log
|
|
|
|
def test_uses_a_custom_filename_and_commit_message(self) -> None:
|
|
"""Uses a custom filename and commit message."""
|
|
client = _mock_client()
|
|
|
|
sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id="pc",
|
|
path_prefix="devices",
|
|
),
|
|
{},
|
|
LogCodec(
|
|
commit_message="custom message",
|
|
decode=_decode,
|
|
encode=_encode,
|
|
filename="notes.json",
|
|
),
|
|
)
|
|
|
|
client.put_file_text.assert_called_once_with(
|
|
"devices/pc/notes.json",
|
|
"{}",
|
|
message="custom message",
|
|
)
|