mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Cover the recovery branches, restoring the 100% gate
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
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
The previous session reported the new code green, but measured it with --no-cov (step 1 of finish_2026_08_24.sh passes that flag), so seven branches in the recovery path had never been exercised. Coverage was 99.78%. Most of the gap was the auto-recovery itself: whether healing succeeded, whether the retry after healing succeeded, and what gets recorded when it does not. That is the code which ran in production for the first time today, and none of its outcomes were pinned. Also covers the donor search on a machine with no ~/.config, siblings whose cache is readable but holds no usable token, and the two healthy paths that must stay silent — an always-on "this may be wrong" panel is one nobody reads. Writing the UI test surfaced that collect_source_findings is never empty for a fresh tmp_path, because an empty sync is itself a finding; the stub is there so the branch under test is "nothing to report". 1115 passed, coverage back to 100%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
This commit is contained in:
parent
f4850670ee
commit
075f7bae8c
@ -85,6 +85,41 @@ class TestFindSiblingRefreshToken:
|
||||
"""No source is a real outcome; the caller must hear it, not guess."""
|
||||
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None
|
||||
|
||||
def test_missing_config_root_is_not_an_error(self, tmp_path: Path) -> None:
|
||||
"""A machine with no ~/.config yet has no donor -- and must not crash.
|
||||
|
||||
Recovery runs unattended inside a lock decision, so an exception here
|
||||
would take the screen for a reason that has nothing to do with
|
||||
working out.
|
||||
"""
|
||||
assert (
|
||||
find_sibling_refresh_token(tmp_path / "nope", skip="screen_locker") is None
|
||||
)
|
||||
|
||||
def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None:
|
||||
"""Readable JSON is not the same as a usable credential.
|
||||
|
||||
A sibling mid-logout leaves a well-formed cache whose refresh_token is
|
||||
missing or empty. Returning that would mint nothing and report success.
|
||||
"""
|
||||
for app, payload in (
|
||||
("no_token_app", {"expires_at": "2026-08-24T12:00:00+00:00"}),
|
||||
("empty_token_app", {"refresh_token": ""}),
|
||||
("wrong_type_app", {"refresh_token": 12345}),
|
||||
):
|
||||
cache = tmp_path / app / "firebase_auth.json"
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(payload))
|
||||
_write_cache(
|
||||
tmp_path / "zz_healthy" / "firebase_auth.json",
|
||||
"refresh-good",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
|
||||
|
||||
assert found == ("zz_healthy", "refresh-good")
|
||||
|
||||
|
||||
class TestRecoverSession:
|
||||
"""Turning a borrowed refresh token into our own cached session."""
|
||||
|
||||
@ -9,8 +9,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._source_health import (
|
||||
SourceFinding,
|
||||
collect_source_findings,
|
||||
describe_staleness,
|
||||
explain_findings,
|
||||
)
|
||||
@ -18,6 +20,25 @@ from screen_locker._source_health import (
|
||||
_NOW = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class TestCollectSourceFindings:
|
||||
"""The healthy machine must produce no findings at all.
|
||||
|
||||
An always-on warning is one nobody reads. If a fresh, working sync still
|
||||
printed "this may be wrong", the panel added for 2026-08-24 would become
|
||||
background noise and stop carrying information.
|
||||
"""
|
||||
|
||||
def test_healthy_and_fresh_reports_nothing(self) -> None:
|
||||
"""No dead backend and recent data means silence."""
|
||||
_sync_client.clear_degraded_sources()
|
||||
|
||||
findings = collect_source_findings(
|
||||
newest_synced=_NOW - timedelta(hours=3), now=_NOW
|
||||
)
|
||||
|
||||
assert findings == []
|
||||
|
||||
|
||||
class TestDescribeStaleness:
|
||||
"""Turning "answered fine, but the data is old" into a stated fault."""
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ from crdt_sync import FirebaseAuthError
|
||||
|
||||
from screen_locker import _sync_client, _workout_sync
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._credential_recovery import RecoveryResult
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
from screen_locker.tests._workout_sync_fixtures import _firebase_config
|
||||
|
||||
@ -116,6 +117,101 @@ class TestFirebaseDegradationIsVisible:
|
||||
|
||||
assert _sync_client.degraded_sources() == []
|
||||
|
||||
def test_successful_recovery_returns_a_working_client(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Healing must actually hand back Firebase, not a degraded fallback.
|
||||
|
||||
This is the path that ran for real on 2026-08-24: the cached
|
||||
credential was dead, a sibling app's refresh token rebuilt the
|
||||
session, and the retry succeeded. Nothing may be recorded as
|
||||
degraded -- the source ended up readable.
|
||||
"""
|
||||
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
||||
attempts: list[int] = []
|
||||
|
||||
def _dead_then_alive(_app: object, client: object) -> object:
|
||||
attempts.append(1)
|
||||
if len(attempts) == 1:
|
||||
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
||||
raise FirebaseAuthError(message)
|
||||
return ("mirror", client)
|
||||
|
||||
monkeypatch.setattr(_sync_client, "mirror_client_for", _dead_then_alive)
|
||||
monkeypatch.setattr(
|
||||
_sync_client,
|
||||
"try_recover_firebase_session",
|
||||
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
|
||||
)
|
||||
_sync_client.clear_degraded_sources()
|
||||
github = object()
|
||||
|
||||
assert _workout_sync.remote_client(github) == ("mirror", github)
|
||||
assert _sync_client.degraded_sources() == [], (
|
||||
"a source that recovered is not degraded"
|
||||
)
|
||||
|
||||
def test_recovery_that_still_cannot_sign_in_is_degraded(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A token that mints but still fails must degrade, not pretend.
|
||||
|
||||
Recovery reporting success is not proof the backend is readable, so
|
||||
the retry is what decides. If it raises too, this is the same lockout
|
||||
as before and must be recorded -- silently returning the GitHub
|
||||
mirror is exactly how 2026-08-24 stayed invisible.
|
||||
"""
|
||||
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
||||
|
||||
def _always_dead(*_args: object, **_kwargs: object) -> None:
|
||||
msg = "still 400 after refresh"
|
||||
raise FirebaseAuthError(msg)
|
||||
|
||||
monkeypatch.setattr(_sync_client, "mirror_client_for", _always_dead)
|
||||
monkeypatch.setattr(
|
||||
_sync_client,
|
||||
"try_recover_firebase_session",
|
||||
lambda: RecoveryResult(recovered=True, reason="rebuilt from a sibling"),
|
||||
)
|
||||
_sync_client.clear_degraded_sources()
|
||||
github = object()
|
||||
|
||||
assert _workout_sync.remote_client(github) is github
|
||||
degraded = _sync_client.degraded_sources()
|
||||
assert degraded, "a retry that still fails must be recorded"
|
||||
assert "still 400 after refresh" in degraded[0].reason
|
||||
|
||||
def test_failed_recovery_records_both_the_cause_and_the_attempt(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When healing fails, say why it failed as well as why it was needed.
|
||||
|
||||
This is 2026-08-24 on a machine with no sibling to borrow from. The
|
||||
recorded reason has to carry both halves, because "Firebase is dead"
|
||||
and "and I could not fix it myself" prompt different actions.
|
||||
"""
|
||||
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
||||
|
||||
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||
msg = "HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
||||
raise FirebaseAuthError(msg)
|
||||
|
||||
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
|
||||
monkeypatch.setattr(
|
||||
_sync_client,
|
||||
"try_recover_firebase_session",
|
||||
lambda: RecoveryResult(
|
||||
recovered=False, reason="no sibling app has a cached refresh token"
|
||||
),
|
||||
)
|
||||
_sync_client.clear_degraded_sources()
|
||||
github = object()
|
||||
|
||||
assert _workout_sync.remote_client(github) is github
|
||||
reason = _sync_client.degraded_sources()[0].reason
|
||||
assert "INVALID_LOGIN_CREDENTIALS" in reason
|
||||
assert "no sibling app" in reason
|
||||
|
||||
def test_unconfigured_machine_is_not_degraded(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@ -5,11 +5,14 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from screen_locker import _sync_client, _ui_flows
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestUITransitions:
|
||||
"""Tests for UI state transitions."""
|
||||
@ -192,3 +195,33 @@ class TestShowRetryAndSick:
|
||||
locker.clear_container.assert_called_once()
|
||||
mock_tk.Label.assert_called()
|
||||
mock_tk.Button.assert_called()
|
||||
|
||||
def test_healthy_sources_add_no_diagnosis_block(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With every source healthy the screen must not hedge.
|
||||
|
||||
The "why this may be wrong" panel exists for 2026-08-24, when the
|
||||
machine genuinely could not see the workout. When it *can* see, an
|
||||
unconditional caveat would teach the user to ignore the one message
|
||||
that matters.
|
||||
"""
|
||||
_sync_client.clear_degraded_sources()
|
||||
# A healthy machine has a readable backend AND a non-empty log, which
|
||||
# this tmp_path fixture cannot provide -- an empty sync is itself a
|
||||
# finding. Stub the collector so the branch under test is "nothing to
|
||||
# report", not "no workouts synced".
|
||||
monkeypatch.setattr(_ui_flows, "collect_source_findings", list)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker, "clear_container", MagicMock())
|
||||
|
||||
locker._show_retry_and_sick("Test message")
|
||||
|
||||
shown = " ".join(
|
||||
str(call.kwargs.get("text", "")) for call in mock_tk.Label.call_args_list
|
||||
)
|
||||
assert "may be wrong" not in shown
|
||||
|
||||
Loading…
Reference in New Issue
Block a user