mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today compared against a UTC day while add_scheduled_skip.py wrote a local one, so a skip added near midnight could name a date the chain never matched. freedays owns "what is today" for both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
9.0 KiB
Python
230 lines
9.0 KiB
Python
"""A degraded workout source must never be reported as "no workout".
|
|
|
|
Regression tests for 2026-06-12 and 2026-08-24, when a completed ~2h workout
|
|
sat in Firebase while the PC locked the screen anyway. The PC's Firebase
|
|
credential no longer authenticated, so ``remote_client`` degraded to the
|
|
GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The
|
|
pull then returned zero records and the lock chain read that as "you did not
|
|
train today" rather than "I could not read the place your workouts live".
|
|
|
|
The distinction is the whole point. "No workout" is a statement about the
|
|
user; "could not check" is a statement about the machine, and only one of
|
|
them justifies taking the screen away.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
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 (
|
|
ReachableClient,
|
|
_firebase_config,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import pytest
|
|
|
|
|
|
class TestUnreadableSourceIsNotNoWorkout:
|
|
"""The 2026-08-24 lockout, at the layer that actually took the screen.
|
|
|
|
With Firebase unreadable the PC had NO information about today. It
|
|
reported "No workout logged for today yet" and locked. These assertions
|
|
fail on the old code, which is the point: they encode the difference
|
|
between an answer and a failure to ask.
|
|
"""
|
|
|
|
def _files(self, tmp_path: Path) -> dict[str, Path]:
|
|
return {
|
|
"log_file": tmp_path / "log.json",
|
|
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
|
}
|
|
|
|
def test_lock_reports_unreadable_source_not_absent_workout(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""An empty log plus a dead backend must not read as "you did nothing"."""
|
|
result = explain_lock_decision(
|
|
**self._files(tmp_path),
|
|
sick_history=SickHistory(),
|
|
extended_early_bird=False,
|
|
weekly_minimum_met=False,
|
|
relaxed_day=False,
|
|
degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),),
|
|
)
|
|
|
|
assert result.sources_degraded is True
|
|
assert "firebase" in result.reason.lower()
|
|
|
|
def test_healthy_sources_keep_the_plain_verdict(self, tmp_path: Path) -> None:
|
|
"""With every backend answering, an empty log really does mean none."""
|
|
result = explain_lock_decision(
|
|
**self._files(tmp_path),
|
|
sick_history=SickHistory(),
|
|
extended_early_bird=False,
|
|
weekly_minimum_met=False,
|
|
relaxed_day=False,
|
|
)
|
|
|
|
assert result.sources_degraded is False
|
|
|
|
|
|
class TestFirebaseDegradationIsVisible:
|
|
"""Losing the primary backend must leave a trace the caller can act on."""
|
|
|
|
def test_degradation_is_recorded_not_just_logged(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A dead Firebase must be queryable, not only a log line.
|
|
|
|
The 2026-08-24 run *did* warn. Nothing read the warning, so the lock
|
|
decision was made as if the source were healthy. Recording the failure
|
|
in state is what lets the decision layer tell the two cases apart.
|
|
"""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
|
|
def _boom(*_args: object, **_kwargs: object) -> None:
|
|
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
|
raise FirebaseAuthError(message)
|
|
|
|
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
|
|
_sync_client.clear_degraded_sources()
|
|
github = object()
|
|
|
|
assert _workout_sync.remote_client(github) is github
|
|
degraded = _sync_client.degraded_sources()
|
|
assert degraded, "a failed Firebase sign-in must be recorded"
|
|
assert "INVALID_LOGIN_CREDENTIALS" in degraded[0].reason
|
|
|
|
def test_healthy_firebase_records_nothing(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The happy path must stay silent, or the signal means nothing."""
|
|
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
|
monkeypatch.setattr(
|
|
_sync_client,
|
|
"mirror_client_for",
|
|
lambda _app, client: ReachableClient(("mirror", client)),
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
_workout_sync.remote_client(object())
|
|
|
|
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 ReachableClient(("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()
|
|
|
|
recovered = _workout_sync.remote_client(github)
|
|
assert recovered.identity == ("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:
|
|
"""No Firebase config is a choice, not a failure -- do not cry wolf."""
|
|
monkeypatch.setattr(
|
|
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
|
)
|
|
_sync_client.clear_degraded_sources()
|
|
|
|
_workout_sync.remote_client(object())
|
|
|
|
assert _sync_client.degraded_sources() == []
|