mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 17:47:23 +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
A real StrongLifts session on 2026-08-10 never reached this machine, so it had to be logged as a manual workout -- spending a manual budget slot on a workout that was genuinely verified. The session was in Firebase the whole time; four separate defects each hid it. - pull_synced_workout() read a hardcoded devices/phone/log.json. Sinceffc6c72the phone pushes under a per-install uuid, so that directory has been frozen since 2026-07-17 and the PC kept returning a stale session from 07-27 as if it were current. Both readers now walk EVERY device directory; reinstalls keep minting new ones, so no single path can be right for long. - A session is identified by SHAPE (it has `exercises`), not by a `kind` field the phone never writes. That filter is why the one reader that did walk every directory discarded real sessions, and it also kept PC-origin runnerup/phone_verified records from being handed to a caller expecting a session. - FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so `except GitHubSyncError` never caught it and a DNS failure took the whole locker down (status=1/FAILURE, then a restart loop). Catch the common parent; a pull failure degrades to a warning. - A GitHub token is no longer required to sync. Firebase has been primary sinceeb4ff01, so a Firebase-only machine was reporting "sync is OFF" while syncing fine. Sync also only ran once per locker start, so a workout finished after login was invisible until the next login. workout-sync.timer runs the new headless --sync-only every 15 minutes; remove_systemd.sh now removes every unit the installer creates, not just two of them. workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds factory defaults before the backup is restored, and the first write then exported those defaults over the only off-device copy. Storage permission is now settled before the DB opens, a defaults-only DB refuses to overwrite a richer backup, and the three swallowed storage errors say what broke. Sessions that exist only in Firebase/GitHub are restored into local history, so a reinstalled app has the history it actually earned rather than starting at whatever survived. Matched on (date, start_time), so re-syncing never duplicates and local rows always win. Adds a target-reps editor: progression can only ever RAISE reps, so a rep target a defaults re-seed set wrong could not be corrected from the UI at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Tests for the phone-workout sync pull (crdt-sync transport).
|
|
|
|
Covers the token read, the payload predicates, the per-log record extractors
|
|
and the cross-device merge. The backend selection (``sync_client`` /
|
|
``remote_client``) and the two public pulls live in ``_part2``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock
|
|
|
|
from crdt_sync import GitHubSyncError, Hlc, Record
|
|
import pytest
|
|
|
|
from screen_locker import _workout_sync
|
|
from screen_locker.tests._workout_sync_fixtures import (
|
|
_manual_payload,
|
|
_manual_record_dict,
|
|
_multi_device_client,
|
|
_record_json,
|
|
_session_payload,
|
|
_session_record_dict,
|
|
)
|
|
|
|
# The autouse `isolate_sync_token` fixture (registered repo-wide via the
|
|
# `pytest_plugins` entry for `screen_locker.tests.test_sync_fixtures` in
|
|
# pyproject.toml) 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. The autouse `_no_real_firebase_config` fixture
|
|
# in conftest.py likewise points `CONFIG_FILE` at a nonexistent path, so tests
|
|
# that want the Firebase branch must monkeypatch it back at a file they own.
|
|
|
|
|
|
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 TestIsSessionPayload:
|
|
"""Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``."""
|
|
|
|
def test_true_for_a_dict_with_an_exercises_list(self) -> None:
|
|
assert _workout_sync._is_session_payload(_session_payload()) is True
|
|
|
|
def test_true_even_with_an_empty_exercises_list(self) -> None:
|
|
assert _workout_sync._is_session_payload({"exercises": []}) is True
|
|
|
|
def test_false_for_a_manual_workout(self) -> None:
|
|
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:
|
|
assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False
|
|
|
|
def test_false_for_a_non_dict_payload(self) -> None:
|
|
assert _workout_sync._is_session_payload("not-a-dict") is False
|
|
assert _workout_sync._is_session_payload(None) is False
|
|
|
|
|
|
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(_session_payload()) is False
|
|
assert _workout_sync._is_manual_payload("not-a-dict") is False
|
|
|
|
|
|
class TestSessionRecords:
|
|
def test_returns_only_session_records(self) -> None:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
log = {"a": {"id": "a"}} # missing "fields"
|
|
with pytest.raises(KeyError):
|
|
_workout_sync._session_records(json.dumps(log))
|
|
|
|
|
|
class TestManualRecords:
|
|
def test_returns_only_manual_records(self) -> None:
|
|
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:
|
|
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]))
|
|
|
|
|
|
class TestMergeDeviceRecords:
|
|
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
|
|
|
|
def test_merges_records_across_every_device(self) -> None:
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{"a": _session_record_dict("session:a", _session_payload())}
|
|
),
|
|
"pc": json.dumps(
|
|
{"b": _session_record_dict("session:b", _session_payload())}
|
|
),
|
|
}
|
|
)
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert sorted(merged) == ["session:a", "session:b"]
|
|
|
|
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{
|
|
"a": _session_record_dict(
|
|
"session:a",
|
|
_session_payload(note="OLD"),
|
|
wall_time_ms=100,
|
|
)
|
|
}
|
|
),
|
|
"pc": json.dumps(
|
|
{
|
|
"a": _session_record_dict(
|
|
"session:a",
|
|
_session_payload(note="NEW"),
|
|
wall_time_ms=200,
|
|
)
|
|
}
|
|
),
|
|
}
|
|
)
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert len(merged) == 1
|
|
assert merged["session:a"][0]["note"] == "NEW"
|
|
|
|
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
|
|
client = _multi_device_client(
|
|
{
|
|
"phone": json.dumps(
|
|
{
|
|
"a": _session_record_dict(
|
|
"session:a",
|
|
_session_payload(note="NEW"),
|
|
wall_time_ms=200,
|
|
)
|
|
}
|
|
),
|
|
"pc": json.dumps(
|
|
{
|
|
"a": _session_record_dict(
|
|
"session:a",
|
|
_session_payload(note="OLD"),
|
|
wall_time_ms=100,
|
|
)
|
|
}
|
|
),
|
|
}
|
|
)
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert merged["session:a"][0]["note"] == "NEW"
|
|
|
|
def test_skips_a_corrupt_device_log(self) -> None:
|
|
client = _multi_device_client(
|
|
{
|
|
"good": json.dumps(
|
|
{"a": _session_record_dict("session:a", _session_payload())}
|
|
),
|
|
"corrupt": "{not json",
|
|
}
|
|
)
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert list(merged) == ["session:a"]
|
|
|
|
def test_skips_an_unreachable_device(self) -> None:
|
|
client = _multi_device_client(
|
|
{
|
|
"good": json.dumps(
|
|
{"a": _session_record_dict("session:a", _session_payload())}
|
|
),
|
|
"gone": GitHubSyncError("404"),
|
|
"empty": None,
|
|
}
|
|
)
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert list(merged) == ["session:a"]
|
|
|
|
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
|
|
client = MagicMock()
|
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
|
merged = _workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert merged == {}
|
|
|
|
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
|
|
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
|
|
client = MagicMock()
|
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
|
with pytest.raises(GitHubSyncError, match="offline"):
|
|
_workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions", strict=True
|
|
)
|
|
|
|
def test_warns_about_a_listing_failure(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A total pull failure must never be silent."""
|
|
client = MagicMock()
|
|
client.list_directory.side_effect = GitHubSyncError("offline")
|
|
with caplog.at_level("WARNING"):
|
|
_workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert "offline" in caplog.text
|
|
|
|
def test_warns_about_a_skipped_device(
|
|
self, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
client = _multi_device_client({"gone": GitHubSyncError("404")})
|
|
with caplog.at_level("WARNING"):
|
|
_workout_sync._merge_device_records(
|
|
client, _workout_sync._session_records, "sessions"
|
|
)
|
|
assert "SKIPPING that device" in caplog.text
|