mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.
- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
only, proven with can_access_remote(), healed once from a sibling app's
session, else a warning naming what will not sync (push AND pull) plus a
recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
(the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
"firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
install_systemd.sh (the installer still said v0.9.0).
kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
"""Tests for the phone-workout sync pull (crdt-sync transport).
|
|
|
|
Covers the payload predicates, the per-log record extractors
|
|
and the cross-device merge. The backend selection (``sync_client``)
|
|
and the two public pulls live in ``_part2``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from crdt_sync import Hlc, Record
|
|
import pytest
|
|
|
|
from screen_locker import _sync_records, _workout_sync
|
|
from screen_locker.tests._workout_sync_fixtures import (
|
|
_manual_payload,
|
|
_manual_record_dict,
|
|
_record_json,
|
|
_session_payload,
|
|
_session_record_dict,
|
|
)
|
|
|
|
# The autouse `_no_real_firebase_config` fixture in conftest.py points
|
|
# `_sync_client.CONFIG_FILE` at a nonexistent path, so tests that want the
|
|
# Firebase branch must monkeypatch it back at a file they own.
|
|
|
|
|
|
class TestIsSessionPayload:
|
|
"""Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``."""
|
|
|
|
def test_true_for_a_dict_with_an_exercises_list(self) -> None:
|
|
"""True for a dict with an exercises list."""
|
|
assert _workout_sync._is_session_payload(_session_payload()) is True
|
|
|
|
def test_true_even_with_an_empty_exercises_list(self) -> None:
|
|
"""True even with an empty exercises list."""
|
|
assert _workout_sync._is_session_payload({"exercises": []}) is True
|
|
|
|
def test_false_for_a_manual_workout(self) -> None:
|
|
"""False for a manual workout."""
|
|
assert _workout_sync._is_session_payload(_manual_payload()) is False
|
|
|
|
def test_false_for_verified_records_sharing_the_same_log(self) -> None:
|
|
"""``*_verified`` records are non-manual but have no ``exercises``."""
|
|
assert _workout_sync._is_session_payload({"kind": "runnerup_verified"}) is False
|
|
assert _workout_sync._is_session_payload({"kind": "phone_verified"}) is False
|
|
|
|
def test_false_when_exercises_is_not_a_list(self) -> None:
|
|
"""False when exercises is not a list."""
|
|
assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False
|
|
|
|
def test_false_for_a_non_dict_payload(self) -> None:
|
|
"""False for a non dict payload."""
|
|
assert _workout_sync._is_session_payload("not-a-dict") is False
|
|
assert _workout_sync._is_session_payload(None) is False
|
|
|
|
|
|
class TestIsManualPayload:
|
|
"""IsManualPayload."""
|
|
|
|
def test_true_for_manual_kind(self) -> None:
|
|
"""True for manual kind."""
|
|
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
|
|
|
|
def test_false_for_session_or_non_dict(self) -> None:
|
|
"""False for session or non dict."""
|
|
assert _workout_sync._is_manual_payload(_session_payload()) is False
|
|
assert _workout_sync._is_manual_payload("not-a-dict") is False
|
|
|
|
|
|
class TestSessionRecords:
|
|
"""SessionRecords."""
|
|
|
|
def test_returns_only_session_records(self) -> None:
|
|
"""Returns only session records."""
|
|
log = {
|
|
"s": _session_record_dict("session:1", _session_payload()),
|
|
"m": _manual_record_dict("manual:1", _manual_payload()),
|
|
"v": _manual_record_dict("verified:1", {"kind": "runnerup_verified"}),
|
|
}
|
|
result = _workout_sync._session_records(json.dumps(log))
|
|
assert list(result) == ["session:1"]
|
|
|
|
def test_skips_records_without_a_payload_field(self) -> None:
|
|
"""Skips records without a payload field."""
|
|
no_payload = Record(
|
|
id="a",
|
|
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
|
|
)
|
|
log = {"a": no_payload.to_dict()}
|
|
assert _workout_sync._session_records(json.dumps(log)) == {}
|
|
|
|
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
|
"""Raises type error when top level is not an object."""
|
|
with pytest.raises(TypeError, match="not a JSON object"):
|
|
_workout_sync._session_records(json.dumps([1, 2, 3]))
|
|
|
|
def test_raises_value_error_for_invalid_json(self) -> None:
|
|
"""Raises value error for invalid JSON."""
|
|
with pytest.raises(ValueError, match="Expecting property name"):
|
|
_workout_sync._session_records("{not valid json")
|
|
|
|
def test_raises_key_error_for_a_malformed_record(self) -> None:
|
|
"""Raises key error for a malformed record."""
|
|
log = {"a": {"id": "a"}} # missing "fields"
|
|
with pytest.raises(KeyError):
|
|
_workout_sync._session_records(json.dumps(log))
|
|
|
|
|
|
class TestManualRecords:
|
|
"""ManualRecords."""
|
|
|
|
def test_returns_only_manual_records(self) -> None:
|
|
"""Returns only manual records."""
|
|
log = {
|
|
"s": _record_json(_session_payload()),
|
|
"m": _manual_record_dict("manual:1", _manual_payload()),
|
|
}
|
|
result = _workout_sync._manual_records(json.dumps(log))
|
|
assert list(result) == ["manual:1"]
|
|
|
|
def test_skips_records_without_a_payload_field(self) -> None:
|
|
"""Skips records without a payload field."""
|
|
no_payload = Record(
|
|
id="a",
|
|
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
|
|
)
|
|
log = {"a": no_payload.to_dict()}
|
|
assert _workout_sync._manual_records(json.dumps(log)) == {}
|
|
|
|
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
|
"""Raises type error when top level is not an object."""
|
|
with pytest.raises(TypeError, match="not a JSON object"):
|
|
_workout_sync._manual_records(json.dumps([1, 2, 3]))
|
|
|
|
|
|
class TestTombstonedIds:
|
|
"""TombstonedIds."""
|
|
|
|
def test_returns_only_the_deleted_ids(self) -> None:
|
|
"""Returns only the deleted ids."""
|
|
hlc = Hlc(wall_time_ms=1, counter=0, node_id="phone")
|
|
live = Record(id="live", fields={"payload": ({"a": 1}, hlc)})
|
|
gone = Record(
|
|
id="gone",
|
|
fields={"payload": ({"a": 1}, hlc)},
|
|
deleted=True,
|
|
deleted_hlc=hlc,
|
|
)
|
|
log = {"x": live.to_dict(), "y": gone.to_dict()}
|
|
assert _sync_records._tombstoned_ids(json.dumps(log)) == {"gone"}
|
|
|
|
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
|
"""Raises type error when top level is not an object."""
|
|
with pytest.raises(TypeError, match="not a JSON object"):
|
|
_sync_records._tombstoned_ids(json.dumps([1, 2, 3]))
|