mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Both surfaces of this repo move together, because they share one prefix: screen_locker (the PC, which reads what the phone logged) and workout_app (the phone, which writes). Firebase becomes the primary backend when it is set up, with GitHub kept as a mirror so a device that has not moved yet is still seen. MirrorSyncClient reads the union of both, so a workout logged against either backend still counts -- which is what lets the PC and the phone cut over on different days. The PC's two pulls are read-only, so they need only the client swapped. _manual_push does a real sync_log and now carries a revision cache, as does every push in workout_app: without it each push re-uploads the whole log and each pull re-downloads every peer's, regardless of change. remote_client is public rather than _private because _manual_push imports it; one decision point beats two copies that can drift. The config file is checked before constructing anything, so an unconfigured machine never reaches the network -- without that guard the PC's sync tests asserted against live Firebase data instead of their own fakes, which is how this was caught. screen_locker: 904 tests at 100% branch coverage. workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across three minor versions with no API drift. Four assertions moved off `.single`, which now finds two writes: the log, then the revision published after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""Tests for the phone-workout GitHub sync pull (crdt-sync transport)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
|
|
import pytest
|
|
|
|
from screen_locker import _workout_sync
|
|
|
|
# The autouse `_isolate_sync_token` fixture in conftest.py already redirects
|
|
# `_workout_sync.SYNC_TOKEN_FILE` into a per-test tmp_path, so tests here can
|
|
# write to it directly without needing their own isolation fixture.
|
|
|
|
|
|
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
|
|
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
|
record = Record(id="x", fields={"payload": (payload, hlc)})
|
|
return record.to_dict()
|
|
|
|
|
|
class TestReadSyncToken:
|
|
def test_returns_none_when_file_is_missing(self) -> None:
|
|
assert _workout_sync.read_sync_token() is None
|
|
|
|
def test_returns_none_when_file_is_empty(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text(" \n")
|
|
assert _workout_sync.read_sync_token() is None
|
|
|
|
def test_returns_the_stripped_token(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n")
|
|
assert _workout_sync.read_sync_token() == "abc123"
|
|
|
|
|
|
class TestLatestPayload:
|
|
def test_returns_none_for_an_empty_log(self) -> None:
|
|
assert _workout_sync._latest_payload("{}") is None
|
|
|
|
def test_returns_the_only_records_payload(self) -> None:
|
|
log = {"a": _record_json({"succeeded": True})}
|
|
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
|
|
def test_returns_the_payload_with_the_greatest_clock(self) -> None:
|
|
log = {
|
|
"old": _record_json({"succeeded": False}, wall_time_ms=100),
|
|
"new": _record_json({"succeeded": True}, wall_time_ms=200),
|
|
}
|
|
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
|
|
def test_skips_records_with_no_payload_field(self) -> None:
|
|
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._latest_payload(json.dumps(log)) is None
|
|
|
|
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
|
|
with pytest.raises(TypeError, match="not a JSON object"):
|
|
_workout_sync._latest_payload(json.dumps([1, 2, 3]))
|
|
|
|
def test_raises_type_error_when_payload_field_is_not_an_object(self) -> None:
|
|
log = {"a": _record_json("not-a-dict")}
|
|
with pytest.raises(TypeError, match="payload field is not a JSON object"):
|
|
_workout_sync._latest_payload(json.dumps(log))
|
|
|
|
def test_raises_key_error_for_a_malformed_record(self) -> None:
|
|
log = {"a": {"id": "a"}} # missing "fields"
|
|
with pytest.raises(KeyError):
|
|
_workout_sync._latest_payload(json.dumps(log))
|
|
|
|
def test_raises_value_error_for_invalid_json(self) -> None:
|
|
with pytest.raises(ValueError, match="Expecting property name"):
|
|
_workout_sync._latest_payload("{not valid json")
|
|
|
|
|
|
def _mock_client(*, get_file_text: object) -> MagicMock:
|
|
client = MagicMock()
|
|
if isinstance(get_file_text, Exception):
|
|
client.get_file_text.side_effect = get_file_text
|
|
else:
|
|
client.get_file_text.return_value = get_file_text
|
|
return client
|
|
|
|
|
|
class TestPullSyncedWorkout:
|
|
def test_returns_none_none_when_no_token_is_configured(self) -> None:
|
|
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
|
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
|
client_cls.assert_not_called()
|
|
|
|
def test_returns_the_error_message_on_a_sync_error(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _mock_client(get_file_text=GitHubSyncError("offline"))
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
assert _workout_sync.pull_synced_workout() == (None, "offline")
|
|
|
|
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _mock_client(get_file_text=None)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
assert _workout_sync.pull_synced_workout() == (None, None)
|
|
|
|
def test_returns_the_payload_on_success(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
log = json.dumps({"a": _record_json({"succeeded": True})})
|
|
client = _mock_client(get_file_text=log)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
assert _workout_sync.pull_synced_workout() == ({"succeeded": True}, None)
|
|
|
|
def test_returns_a_corrupt_data_message_on_malformed_sync_data(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _mock_client(get_file_text="{not valid json")
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
data, error = _workout_sync.pull_synced_workout()
|
|
assert data is None
|
|
assert error is not None
|
|
assert "corrupt sync data" in error
|
|
|
|
|
|
def _manual_payload(**extra: object) -> dict:
|
|
payload = {"kind": "manual_workout", "date": "2026-07-13"}
|
|
payload.update(extra)
|
|
return payload
|
|
|
|
|
|
def _manual_record_dict(
|
|
record_id: str, payload: dict, *, wall_time_ms: int = 1000
|
|
) -> dict:
|
|
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
|
|
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
|
|
|
|
|
|
class TestLatestPayloadSkipsManual:
|
|
def test_manual_only_log_yields_no_session(self) -> None:
|
|
log = {"m": _manual_record_dict("manual:1", _manual_payload())}
|
|
assert _workout_sync._latest_payload(json.dumps(log)) is None
|
|
|
|
def test_returns_session_even_when_a_later_manual_exists(self) -> None:
|
|
log = {
|
|
"s": _record_json({"succeeded": True}, wall_time_ms=100),
|
|
"m": _manual_record_dict("manual:1", _manual_payload(), wall_time_ms=999),
|
|
}
|
|
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
|
|
|
|
|
|
class TestIsManualPayload:
|
|
def test_true_for_manual_kind(self) -> None:
|
|
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
|
|
|
|
def test_false_for_session_or_non_dict(self) -> None:
|
|
assert _workout_sync._is_manual_payload({"succeeded": True}) is False
|
|
assert _workout_sync._is_manual_payload("not-a-dict") is False
|
|
|
|
|
|
class TestManualRecords:
|
|
def test_returns_only_manual_records(self) -> None:
|
|
log = {
|
|
"s": _record_json({"succeeded": True}),
|
|
"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:
|
|
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:
|
|
with pytest.raises(TypeError, match="not a JSON object"):
|
|
_workout_sync._manual_records(json.dumps([1, 2, 3]))
|
|
|
|
|
|
def _multi_device_client(device_logs: dict[str, object]) -> MagicMock:
|
|
client = MagicMock()
|
|
client.list_directory.return_value = list(device_logs)
|
|
|
|
def _get(path: str) -> object:
|
|
device = path.split("/")[-2]
|
|
value = device_logs[device]
|
|
if isinstance(value, Exception):
|
|
raise value
|
|
return value
|
|
|
|
client.get_file_text.side_effect = _get
|
|
return client
|
|
|
|
|
|
class TestPullAllManualRecords:
|
|
def test_returns_empty_when_no_token(self) -> None:
|
|
assert _workout_sync.pull_all_manual_records() == []
|
|
|
|
def test_returns_empty_when_listing_fails(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = MagicMock()
|
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
assert _workout_sync.pull_all_manual_records() == []
|
|
|
|
def test_merges_manual_records_across_devices(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
|
),
|
|
"pc": json.dumps(
|
|
{"b": _manual_record_dict("manual:b", _manual_payload())}
|
|
),
|
|
}
|
|
)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
result = _workout_sync.pull_all_manual_records()
|
|
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
|
|
|
|
def test_skips_missing_and_corrupt_device_logs(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{"a": _manual_record_dict("manual:a", _manual_payload())}
|
|
),
|
|
"gone": GitHubSyncError("404"),
|
|
"empty": None,
|
|
"corrupt": "{not json",
|
|
}
|
|
)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
result = _workout_sync.pull_all_manual_records()
|
|
assert [rid for rid, _ in result] == ["manual:a"]
|
|
|
|
def test_dedups_same_id_keeping_highest_clock(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{
|
|
"a": _manual_record_dict(
|
|
"manual:a",
|
|
_manual_payload(cost="OLD"),
|
|
wall_time_ms=100,
|
|
)
|
|
}
|
|
),
|
|
"pc": json.dumps(
|
|
{
|
|
"a": _manual_record_dict(
|
|
"manual:a",
|
|
_manual_payload(cost="NEW"),
|
|
wall_time_ms=200,
|
|
)
|
|
}
|
|
),
|
|
}
|
|
)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
result = _workout_sync.pull_all_manual_records()
|
|
assert len(result) == 1
|
|
assert result[0][1]["cost"] == "NEW"
|
|
|
|
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
|
|
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{
|
|
"a": _manual_record_dict(
|
|
"manual:a",
|
|
_manual_payload(cost="NEW"),
|
|
wall_time_ms=200,
|
|
)
|
|
}
|
|
),
|
|
"pc": json.dumps(
|
|
{
|
|
"a": _manual_record_dict(
|
|
"manual:a",
|
|
_manual_payload(cost="OLD"),
|
|
wall_time_ms=100,
|
|
)
|
|
}
|
|
),
|
|
}
|
|
)
|
|
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
|
|
result = _workout_sync.pull_all_manual_records()
|
|
assert len(result) == 1
|
|
assert result[0][1]["cost"] == "NEW"
|
|
|
|
|
|
class TestRemoteClient:
|
|
"""Which backend the workout pull reads from during the cutover."""
|
|
|
|
def test_stays_on_github_without_firebase_config(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An unconfigured machine must not reach the network at all."""
|
|
monkeypatch.setattr(
|
|
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
|
)
|
|
github = object()
|
|
|
|
assert _workout_sync.remote_client(github) is github
|
|
|
|
def test_mirrors_to_github_when_configured(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Configured: reads union both, so either backend's workout counts."""
|
|
config = tmp_path / "firebase.json"
|
|
config.write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
|
|
monkeypatch.setattr(
|
|
_workout_sync,
|
|
"mirror_client_for",
|
|
lambda _app, client: ("mirror", client),
|
|
)
|
|
github = object()
|
|
|
|
assert _workout_sync.remote_client(github) == ("mirror", github)
|
|
|
|
def test_falls_back_when_firebase_is_unusable(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A broken Firebase must degrade to GitHub, never fail the pull."""
|
|
config = tmp_path / "firebase.json"
|
|
config.write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
|
|
|
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
|
message = "no password"
|
|
raise ConfigError(message)
|
|
|
|
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
|
|
github = object()
|
|
|
|
assert _workout_sync.remote_client(github) is github
|