Cover the headless dispatch and the degraded-source annotation
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
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

Two gaps against the 100% bar. The first is mine: --log-manual-workout's
dispatch branch had no test, so nothing checked that the flag's own name is
stripped before the args are forwarded, or that the exit code is the logger's.

The second predates this work. `_record_decision` annotates the DECISION line
with any backend it could not read, which is the thing that distinguishes
"weekly=0/5, measured" from "weekly=0/5, unreadable" -- the exact confusion
that hid the 2026-08 outage -- and that branch was never exercised. Now both
directions are: named when a source is degraded, absent when none are.

test_sync_degradation.py was at the 250-line cap, so the new class lives in
test_sync_degradation_part2.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 20:55:05 +02:00
parent 9131c8086e
commit fa8fa208d8
2 changed files with 121 additions and 0 deletions

View File

@ -173,3 +173,51 @@ class TestScreenLockDelegation:
"""main is the module's public entry point."""
assert _cli.__all__ == ["main"]
assert callable(_cli.main)
class TestLogManualWorkoutMode:
"""--log-manual-workout hands everything after the flag to the logger.
Dispatch only: the field rules themselves are covered by
``test_manual_cli.py`` against the real validator.
"""
def test_forwards_the_remaining_args_and_exits_with_its_code(self) -> None:
"""The flag's own name is not passed on, and the exit code is used."""
seen: list[tuple[object, list[str]]] = []
def _fake_run(log_file: object, argv: list[str]) -> int:
"""Record the call and report a refusal."""
seen.append((log_file, list(argv)))
return 1
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_manual_log", _fake_run),
patch.object(_cli.sys, "exit") as sys_exit,
):
_cli.main(
_FakeLocker,
["screen_lock.py", "--log-manual-workout", "--sport", "other"],
)
(log_file, argv) = seen[0]
assert argv == ["--sport", "other"]
assert log_file.name == "log.json"
sys_exit.assert_called_once_with(1)
def test_exits_before_reaching_the_lock_screen(self) -> None:
"""A successful log exits 0 rather than falling through to the UI.
``sys.exit`` is a no-op under the suite-wide fixture, so this asserts
on the exit call: in the real process that call is what stops the
headless path from continuing on to build a lock screen.
"""
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_manual_log", lambda *_: 0),
patch.object(_cli.sys, "exit") as sys_exit,
):
_cli.main(_FakeLocker, ["screen_lock.py", "--log-manual-workout"])
assert sys_exit.call_args_list[0].args == (0,)

View File

@ -0,0 +1,73 @@
"""The DECISION line must name a backend it could not read.
Split from ``test_sync_degradation.py`` to keep both files under the 250-line
cap; same subject, which is that "could not check" and "did not train" are
different claims and only one of them justifies locking the screen.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker import _startup_checks, _sync_client
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestDecisionLineNamesTheDeadBackend:
"""A degraded read must be named on the DECISION line itself.
A warning logged 90 seconds earlier is not enough: the line that says
``weekly=0/5`` has to say whether that zero was measured or merely
unreadable, or a reader tracing the journal draws the wrong conclusion
about the user (see this module's docstring).
"""
def _locker(self, tmp_path: Path) -> object:
"""A StartupChecksMixin with __init__ bypassed, as the CLI builds it."""
locker = object.__new__(_startup_checks.StartupChecksMixin)
locker.log_file = tmp_path / "log.json"
# Stubbed: the ladder annotation is its own concern, tested elsewhere.
locker._other_conditions = lambda _reason: {}
return locker
def test_annotates_the_decision_with_the_unreadable_source(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The dead backend's name reaches the recorded decision."""
recorded: list[object] = []
monkeypatch.setattr(
_startup_checks,
"degraded_sources",
lambda: (_sync_client.DegradedSource("firebase", "HTTP 401"),),
)
monkeypatch.setattr(_startup_checks, "count_weekly_workouts", lambda _: 0)
monkeypatch.setattr(
_startup_checks, "record_decision", lambda d, **_: recorded.append(d)
)
self._locker(tmp_path)._record_decision(
locked=False, reason="weekly_minimum_met", detail=""
)
assert recorded[0].extra["unreadable_sources"] == "firebase"
def test_healthy_sources_add_no_annotation(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With every backend answering, the line stays unannotated."""
recorded: list[object] = []
monkeypatch.setattr(_startup_checks, "degraded_sources", tuple)
monkeypatch.setattr(_startup_checks, "count_weekly_workouts", lambda _: 0)
monkeypatch.setattr(
_startup_checks, "record_decision", lambda d, **_: recorded.append(d)
)
self._locker(tmp_path)._record_decision(
locked=False, reason="weekly_minimum_met", detail=""
)
assert "unreadable_sources" not in recorded[0].extra