screen-locker/screen_locker/tests/test_sync_status.py
Krzysztof kuhy Rudnicki b008992cb7
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
Show which sync backend the PC uses in the status window
The status window is the PC's surface for workout data, but it said nothing
about the sync layer that data now arrives through -- so a machine that had
silently fallen back to the GitHub mirror, or was configured but had never
completed a push, looked identical to a healthy one. That is exactly how
todo's desktop app sat on GitHub unnoticed while every other surface moved to
Firebase.

Adds a "Sync backend" section and a --sync CLI flag (for i3blocks, alongside
--summary/--state), both rendering one line: which backend this machine is
configured for, when it last pushed, and how many peers it has merged.

Deliberately on-disk only -- no RTDB read, no Firebase sign-in -- because
gather_status() runs on every i3blocks tick and every window open; a network
round-trip there would be rate-limited nonsense. Everything shown is
knowable from the revision cache plus which credential files exist.

Coloured by `healthy` (configured AND has pushed) rather than by backend,
because "configured but never pushed" is the failure that looks fine in every
settings screen and produces no data.

Named _section_sync_backend, not _section_sync: another session is adding a
_section_sync that reports the outcome of a background sync *tick*. The two
are complementary -- did the tick run, versus what is this machine wired to
-- so they coexist rather than one overwriting the other.

Verified by running it, not just the suite: the real window shows
"firebase · pushed 2026-08-09 21:06 · 2 peer(s)" against this machine's live
state, and `--sync` prints the same line.

924 tests, 100% branch coverage. Every except logs at warning per the repo's
no-silent-failures hook; no suppressions added.

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

224 lines
7.0 KiB
Python

"""Tests for the on-disk sync posture shown in the status view.
The point of this module is that it never touches the network, so every test
here drives it purely from files in ``tmp_path``.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._sync_status import (
SyncStatus,
format_sync_line,
gather_sync_status,
)
from screen_locker.status_view import main
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _gather(
tmp_path: Path,
*,
state_file: Path | None = None,
token_file: Path | None = None,
firebase_config: Path | None = None,
) -> SyncStatus:
"""Gather status against files under ``tmp_path`` unless overridden."""
return gather_sync_status(
state_file=state_file or tmp_path / "sync_state.json",
token_file=token_file or tmp_path / "sync_token",
firebase_config=firebase_config or tmp_path / "firebase.json",
)
class TestBackendDetection:
"""Which backend this machine is configured for, from files alone."""
def test_no_credentials_is_not_configured(self, tmp_path: Path) -> None:
"""No credentials is not configured."""
assert _gather(tmp_path).backend == "none"
def test_a_github_token_alone_is_the_mirror_path(self, tmp_path: Path) -> None:
"""A github token alone is the mirror path."""
(tmp_path / "sync_token").write_text("tok")
assert _gather(tmp_path).backend == "github"
def test_firebase_wins_when_both_exist(self, tmp_path: Path) -> None:
"""Firebase is primary; GitHub is only the cutover mirror."""
(tmp_path / "sync_token").write_text("tok")
(tmp_path / "firebase.json").write_text("{}")
assert _gather(tmp_path).backend == "firebase"
class TestPushState:
"""What the revision cache says about pushes and peers."""
def test_no_state_file_means_never_pushed(self, tmp_path: Path) -> None:
"""No state file means never pushed."""
status = _gather(tmp_path)
assert status.pushed is False
assert status.peer_count == 0
assert status.last_push is None
def test_a_pushed_rev_counts_as_pushed(self, tmp_path: Path) -> None:
"""A pushed rev counts as pushed."""
(tmp_path / "sync_state.json").write_text(
json.dumps({"pushed_rev": "abc", "peer_revs": {"a": "1", "b": "2"}})
)
status = _gather(tmp_path)
assert status.pushed is True
assert status.peer_count == 2
assert status.last_push is not None
def test_a_corrupt_state_file_reads_as_nothing_known(self, tmp_path: Path) -> None:
"""A broken cache must not stop the status window from opening."""
(tmp_path / "sync_state.json").write_text("{not json")
status = _gather(tmp_path)
assert status.pushed is False
assert status.peer_count == 0
def test_a_non_object_state_file_reads_as_nothing_known(
self, tmp_path: Path
) -> None:
(tmp_path / "sync_state.json").write_text("[]")
assert _gather(tmp_path).pushed is False
def test_a_non_dict_peer_revs_counts_as_no_peers(self, tmp_path: Path) -> None:
"""A non dict peer revs counts as no peers."""
(tmp_path / "sync_state.json").write_text(
json.dumps({"pushed_rev": "abc", "peer_revs": "not-a-map"})
)
assert _gather(tmp_path).peer_count == 0
def test_an_unstattable_state_file_has_no_timestamp(self, tmp_path: Path) -> None:
"""A path whose parent is a file cannot be stat'd; that is not fatal."""
blocker = tmp_path / "blocker"
blocker.write_text("not a directory")
status = _gather(tmp_path, state_file=blocker / "sync_state.json")
assert status.last_push is None
class TestHealthy:
"""Configured AND actually pushing -- the distinction that matters."""
def test_unconfigured_is_not_healthy(self) -> None:
"""Unconfigured is not healthy."""
status = SyncStatus(
device_id="u", backend="none", pushed=False, peer_count=0, last_push=None
)
assert status.healthy is False
def test_configured_but_never_pushed_is_not_healthy(self) -> None:
"""The interesting failure: looks fine everywhere, produces no data."""
status = SyncStatus(
device_id="u",
backend="firebase",
pushed=False,
peer_count=0,
last_push=None,
)
assert status.healthy is False
def test_configured_and_pushed_is_healthy(self) -> None:
"""Configured and pushed is healthy."""
status = SyncStatus(
device_id="u", backend="firebase", pushed=True, peer_count=1, last_push="x"
)
assert status.healthy is True
class TestFormatSyncLine:
"""The one-line rendering used by the window and i3blocks."""
def test_unconfigured_says_so(self) -> None:
"""Unconfigured says so."""
status = SyncStatus(
device_id="u", backend="none", pushed=False, peer_count=0, last_push=None
)
assert format_sync_line(status) == "sync not configured"
def test_never_pushed_names_the_backend(self) -> None:
"""Never pushed names the backend."""
status = SyncStatus(
device_id="u",
backend="firebase",
pushed=False,
peer_count=0,
last_push=None,
)
assert "never pushed" in format_sync_line(status)
assert "firebase" in format_sync_line(status)
def test_pushed_with_peers_reports_both(self) -> None:
"""Pushed with peers reports both."""
status = SyncStatus(
device_id="u",
backend="firebase",
pushed=True,
peer_count=2,
last_push="2026-08-09 19:05",
)
line = format_sync_line(status)
assert "2026-08-09 19:05" in line
assert "2 peer(s)" in line
def test_pushed_with_no_peers_says_no_peers(self) -> None:
"""Pushed with no peers says no peers."""
status = SyncStatus(
device_id="u",
backend="firebase",
pushed=True,
peer_count=0,
last_push="2026-08-09 19:05",
)
assert "no peers" in format_sync_line(status)
class TestSyncCliFlag:
"""The --sync flag i3blocks calls."""
def test_sync_flag_prints_the_backend_line(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""--sync must stay on-disk only: safe on every i3blocks tick."""
with patch(
"screen_locker.status_view.gather_sync_status",
return_value=SyncStatus(
device_id="u",
backend="firebase",
pushed=True,
peer_count=2,
last_push="2026-08-09 19:05",
),
):
main(["--sync"])
out = capsys.readouterr().out
assert "firebase" in out
assert "2 peer(s)" in out