diff --git a/screen_locker/tests/test_cli.py b/screen_locker/tests/test_cli.py index 1b1e9c9..c19b069 100644 --- a/screen_locker/tests/test_cli.py +++ b/screen_locker/tests/test_cli.py @@ -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,) diff --git a/screen_locker/tests/test_sync_degradation_part2.py b/screen_locker/tests/test_sync_degradation_part2.py new file mode 100644 index 0000000..6a9d53d --- /dev/null +++ b/screen_locker/tests/test_sync_degradation_part2.py @@ -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