diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index 7052464..0002c71 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -32,6 +32,7 @@ class TestExplainLockDecision: } def test_scheduled_skip_short_circuits(self, tmp_path: Path) -> None: + """A freeday mark stops the chain at the scheduled_skip stage, first in the trace.""" files = self._files(tmp_path) freedays.mark( freedays.today(), @@ -47,6 +48,7 @@ class TestExplainLockDecision: assert result.trace[0].fired is True def test_early_bird_window_still_open_skips(self, tmp_path: Path) -> None: + """An open early-bird window with a wake-alarm skip stops at early_bird_window_open.""" files = self._files(tmp_path) files["early_bird_pending_file"].write_text( json.dumps({"date": _today(), "hmac": "sig"}) @@ -68,6 +70,7 @@ class TestExplainLockDecision: def test_expired_early_bird_falls_through_to_full_lock( self, tmp_path: Path ) -> None: + """An expired early-bird marker leads to would_lock via early_bird_expired.""" files = self._files(tmp_path) files["early_bird_pending_file"].write_text( json.dumps({"date": _today(), "hmac": "sig"}) @@ -91,6 +94,7 @@ class TestExplainLockDecision: assert expired_step.fired is True def test_sick_day_skips(self, tmp_path: Path) -> None: + """A sick day stops the chain at the sick_day stage.""" files = self._files(tmp_path) mark_sick() result = explain_lock_decision( @@ -105,6 +109,7 @@ class TestExplainLockDecision: def test_a_sick_day_that_keeps_the_workout_lock_does_not_skip( self, tmp_path: Path ) -> None: + """A sick day that keeps the workout lock does not fire the sick_day skip.""" files = self._files(tmp_path) mark_sick(keep=[sickdays.LOCKER_WORKOUT]) result = explain_lock_decision( @@ -118,6 +123,7 @@ class TestExplainLockDecision: assert sick_step.fired is False def test_already_logged_skips(self, tmp_path: Path) -> None: + """A signed log entry for today stops the chain at already_logged.""" files = self._files(tmp_path) files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}})) with patch( @@ -133,6 +139,7 @@ class TestExplainLockDecision: assert result.stage == "already_logged" def test_wake_alarm_skip_skips(self, tmp_path: Path) -> None: + """A wake-alarm skip stops the chain at wake_alarm_skip.""" files = self._files(tmp_path) result = explain_lock_decision( **files, @@ -158,6 +165,7 @@ class TestExplainLockDecision: assert "early-bird" not in result.reason def test_relaxed_day_skips(self, tmp_path: Path) -> None: + """A relaxed day stops the chain at the relaxed_day stage.""" files = self._files(tmp_path) result = explain_lock_decision( **files, @@ -190,6 +198,7 @@ class TestExplainLockDecision: assert result.stage == "relaxed_day_already_skipped" def test_weekly_minimum_met_skips(self, tmp_path: Path) -> None: + """A met weekly minimum stops the chain at weekly_minimum_met.""" files = self._files(tmp_path) result = explain_lock_decision( **files, @@ -201,6 +210,7 @@ class TestExplainLockDecision: assert result.stage == "weekly_minimum_met" def test_full_lock_when_nothing_applies(self, tmp_path: Path) -> None: + """With no skip applicable the decision is would_lock with no auto-upgrade.""" files = self._files(tmp_path) result = explain_lock_decision( **files, diff --git a/screen_locker/tests/test_compliance_state_part4.py b/screen_locker/tests/test_compliance_state_part4.py index 8e97610..907b2a4 100644 --- a/screen_locker/tests/test_compliance_state_part4.py +++ b/screen_locker/tests/test_compliance_state_part4.py @@ -14,9 +14,11 @@ class TestEarlyBirdWindowIsTheCarrot: """The window is whatever wake-alarm's signed file says -- no wall clock.""" def test_no_clock_predicate_remains(self) -> None: + """The wall-clock early-bird window predicate no longer exists.""" assert not hasattr(_compliance_state, "_early_bird_window_open") def test_window_open_mirrors_wake_skip(self, tmp_path: Path) -> None: + """The early-bird decision follows wake_skip: true skips, false would lock.""" files = { "log_file": tmp_path / "log.json", "early_bird_pending_file": tmp_path / "eb.json", diff --git a/screen_locker/tests/test_daily_limits_wiring.py b/screen_locker/tests/test_daily_limits_wiring.py index 1090653..2dd7b83 100644 --- a/screen_locker/tests/test_daily_limits_wiring.py +++ b/screen_locker/tests/test_daily_limits_wiring.py @@ -56,6 +56,7 @@ class Host(WorkoutCreditMixin, ShutdownMixin): @pytest.fixture def monday() -> Iterator[None]: + """Pin the credit, shutdown and grace-floor modules to a fixed Monday.""" with ( patch("screen_locker._workout_credit.today_str", return_value=_ISO), patch("screen_locker._shutdown.today_str", return_value=_ISO), @@ -69,9 +70,12 @@ _COUNTED: list[dict[str, Any]] = [{"workout_data": {"type": "runnerup_verified"} @pytest.mark.usefixtures("monday") class TestRestDayCredit: + """Live workout credit on a rest day.""" + def test_the_first_real_workout_earns_nothing_more( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """On a rest day the first real workout adds no time, as it is already in tonight's.""" host = Host(tmp_path, 20 * 60) with ( patch.object(_workout_credit, "is_rest_day", return_value=True), @@ -83,6 +87,7 @@ class TestRestDayCredit: assert "already in tonight's time" in caplog.text def test_a_second_one_earns_a_further_unit(self, tmp_path: Path) -> None: + """A second counted workout on a rest day earns one extra unit.""" host = Host(tmp_path, 20 * 60) extra = _earned.extra_minutes(earned_time.WORKOUT, _grace_date()) with patch.object(_workout_credit, "is_rest_day", return_value=True): @@ -97,7 +102,10 @@ def _grace_date() -> date: @pytest.mark.usefixtures("monday") class TestLiftAbsorption: + """Shutdown adds are paid out of the grace lift first.""" + def test_adds_pay_from_the_lift_first(self, tmp_path: Path) -> None: + """Adds are absorbed by the lift before the shutdown hour moves.""" _grace_floor.save_lift(30) host = Host(tmp_path, 19 * 60 + 55) assert host._adjust_shutdown_time_by(25) @@ -106,6 +114,7 @@ class TestLiftAbsorption: assert host.config[0] == 20 * 60 + 15 def test_a_failed_write_keeps_the_lift(self, tmp_path: Path) -> None: + """A failed config write leaves the saved lift unspent.""" _grace_floor.save_lift(30) host = Host(tmp_path, 1195, ok=False) assert not host._adjust_shutdown_time_by(25) @@ -113,6 +122,7 @@ class TestLiftAbsorption: assert _grace_floor.plan_absorb(0) == (0, 30) def test_the_first_workout_is_day_aware(self, tmp_path: Path) -> None: + """The first-workout add uses the day-aware amount for that date.""" host = Host(tmp_path, 1140) assert host._adjust_shutdown_time_later() first = _earned.first_minutes(earned_time.WORKOUT, _grace_date()) @@ -125,6 +135,7 @@ class TestPreLadderEarnedTime: def test_raw_fields_without_the_ladder_api( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: + """Without the ladder API the accessors use the raw earner fields and warn once.""" for name in ( "shutdown_minutes_for", "extra_shutdown_minutes_for", @@ -144,12 +155,14 @@ class TestPreLadderEarnedTime: assert caplog.text.count("has no first_credit_at") == 1 def test_the_ladder_api_reads_the_ledger(self, tmp_path: Path) -> None: + """With the ladder API a never-credited earner has no first-credit time.""" if not hasattr(earned_time, "first_credit_at"): pytest.skip("installed earned_time predates first_credit_at") assert _earned.first_credit_time(earned_time.ANKI, _grace_date()) is None def test_a_failed_tcx_parse_is_not_marked() -> None: + """A failed TCX parse is passed through as None instead of being marked.""" assert _runnerup_tcx._mark_walk(None, "x_Walking.tcx") is None @@ -161,7 +174,10 @@ class _Bare: class TestCliFlags: + """The new CLI flags dispatch to their runners.""" + def test_declare_rest_day_dispatches_the_rest_of_argv(self) -> None: + """--declare-rest-day hands the remaining argv to its runner and exits with its code.""" with ( patch.object(_cli.logging, "basicConfig"), patch.object(_cli, "run_declare_rest_day", return_value=0) as run, @@ -173,6 +189,7 @@ class TestCliFlags: sys_exit.assert_called_once_with(0) def test_backfill_dispatches_with_the_log(self) -> None: + """--backfill-workout-ledger passes the log file and remaining args to its runner.""" with ( patch.object(_cli.logging, "basicConfig"), patch.object(_cli, "run_backfill", return_value=0) as run, diff --git a/screen_locker/tests/test_decision_reasons.py b/screen_locker/tests/test_decision_reasons.py index e114f02..f09d08d 100644 --- a/screen_locker/tests/test_decision_reasons.py +++ b/screen_locker/tests/test_decision_reasons.py @@ -15,12 +15,14 @@ class TestCollectReasons: """Every condition that holds is reported, not just the first.""" def test_returns_only_the_predicates_that_hold(self) -> None: + """Only the names whose predicate is true are returned, in order.""" held = collect_reasons( {"a": lambda: True, "b": lambda: False, "c": lambda: True} ) assert held == ["a", "c"] def test_empty_when_nothing_holds(self) -> None: + """No true predicate yields an empty list.""" assert collect_reasons({"a": lambda: False}) == [] def test_a_failing_predicate_is_reported_and_skipped( @@ -29,6 +31,7 @@ class TestCollectReasons: """A broken annotation must not take down the decision it annotates.""" def boom() -> bool: + """Predicate that fails with an OSError.""" msg = "log unreadable" raise OSError(msg) @@ -46,6 +49,7 @@ class TestReasonsExtra: """The acting reason is never repeated back as an 'also'.""" def test_excludes_the_acting_reason(self) -> None: + """The acting reason is left out of the 'also' list.""" extra = reasons_extra( "early_bird_window_active", { @@ -56,6 +60,7 @@ class TestReasonsExtra: assert extra == {"also": "workout_logged_today"} def test_orders_and_joins_every_other_condition(self) -> None: + """Every other holding condition is joined in order into the 'also' value.""" extra = reasons_extra( "early_bird_window_active", { diff --git a/screen_locker/tests/test_early_bird.py b/screen_locker/tests/test_early_bird.py index f0f45b2..abfa3a2 100644 --- a/screen_locker/tests/test_early_bird.py +++ b/screen_locker/tests/test_early_bird.py @@ -21,6 +21,7 @@ class TestIsEarlyBirdTime: def test_open_while_the_carrot_is_in_force( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """The early-bird window is open exactly while a morning-session skip is in force.""" locker = create_locker(mock_tk, tmp_path) with patch( "screen_locker._early_bird.morning_skip_today", return_value=_SKIP @@ -32,6 +33,7 @@ class TestIsEarlyBirdTime: def test_shut_without_a_carrot( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """With no morning-session skip the early-bird window is shut.""" locker = create_locker(mock_tk, tmp_path) with patch("screen_locker._early_bird.morning_skip_today", return_value=None): assert locker._is_early_bird_time() is False diff --git a/screen_locker/tests/test_earned.py b/screen_locker/tests/test_earned.py index b5f4c26..205d2e9 100644 --- a/screen_locker/tests/test_earned.py +++ b/screen_locker/tests/test_earned.py @@ -11,7 +11,6 @@ from __future__ import annotations from datetime import datetime, timedelta import json -import logging from typing import TYPE_CHECKING import earned_time @@ -20,7 +19,6 @@ import pytest from screen_locker import _constants, _earned from screen_locker._earned import ( earned_today, - flat_answers, flat_earners, hhmm, ledger_file, @@ -47,12 +45,16 @@ _LEDGERED = pytest.mark.parametrize( @pytest.fixture(name="key") def key_fixture(tmp_path: Path) -> Iterator[Path]: + """Install a valid HMAC signing key under the redirected key path.""" with signing_key(tmp_path) as path: yield path class TestHhmm: + """Groups the ``hhmm`` renderer for minutes-after-midnight.""" + def test_whole_hours_are_zero_padded(self) -> None: + """Whole hours render as zero-padded HH:00.""" assert hhmm(0) == "00:00" assert hhmm(9 * 60) == "09:00" assert hhmm(21 * 60) == "21:00" @@ -63,40 +65,52 @@ class TestHhmm: assert hhmm(5) == "00:05" def test_midnight_cap_is_24_00(self) -> None: + """The 24h cap renders as 24:00 rather than wrapping to 00:00.""" assert hhmm(24 * 60) == "24:00" class TestSpan: + """Groups the ``span`` duration renderer.""" + def test_whole_hours(self) -> None: + """Whole-hour durations render as Nh with no minutes part.""" assert span(0) == "0h" assert span(60) == "1h" assert span(120) == "2h" def test_minutes_only(self) -> None: + """Durations under an hour render as Nm only.""" assert span(30) == "30m" def test_hours_and_minutes_combine(self) -> None: + """Mixed durations render as NhMMm with zero-padded minutes.""" assert span(90) == "1h30m" assert span(125) == "2h05m" class TestLedgerFile: + """Groups how a ledger path is resolved and redirected in the suite.""" + @_LEDGERED def test_resolves_under_ledger_home(self, earner: earned_time.Earner) -> None: + """A ledger file path is the earner's ledger name under LEDGER_HOME.""" assert earner.ledger is not None assert ledger_file(earner) == _earned.LEDGER_HOME / earner.ledger def test_ledger_home_and_key_are_redirected_for_the_suite( self, tmp_path: Path ) -> None: + """The suite points LEDGER_HOME and HMAC_KEY_FILE at tmp_path with no key present.""" assert tmp_path / "ledger_home" == _earned.LEDGER_HOME assert tmp_path / "earned_hmac.key" == _earned.HMAC_KEY_FILE assert not _earned.HMAC_KEY_FILE.exists() def test_suite_never_sees_a_real_ledger(self) -> None: + """No flat earner's ledger path exists during the suite, so real ledgers are never read.""" assert not any(ledger_file(e).exists() for e in flat_earners()) def test_earner_without_a_ledger_raises(self) -> None: + """Asking for the ledger of an earner that has none raises ValueError.""" bare = earned_time.Earner( name="bare", label="Bare", gaming_minutes=0, shutdown_minutes=0 ) @@ -105,7 +119,10 @@ class TestLedgerFile: class TestFlatEarners: + """Groups which registry earners count as flat (ledger-read) earners.""" + def test_registry_order_without_the_counted_workout(self) -> None: + """Flat earners keep registry order and exclude the counted workout earner.""" flat = tuple(e for e in registry() if e.kind == "flat") assert flat_earners() == flat assert flat[:2] == (earned_time.LEETCODE, earned_time.READING) @@ -113,6 +130,7 @@ class TestFlatEarners: def test_a_newly_registered_earner_is_picked_up( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """An earner added to the registry later appears among the flat earners.""" register(monkeypatch, EXTRA) assert flat_earners()[-1] is EXTRA @@ -135,15 +153,19 @@ class TestFlatEarners: @pytest.mark.usefixtures("leetcode_gate_off") class TestEarnedToday: + """Groups the ledger-backed 'did the earner earn today' answer.""" + @_LEDGERED def test_signed_credit_today_earns( self, key: Path, earner: earned_time.Earner ) -> None: + """A validly signed credit dated today counts as earned.""" write_ledger(earner, [credit(earner)]) assert earned_today(earner) is True @_LEDGERED def test_now_is_passed_through(self, key: Path, earner: earned_time.Earner) -> None: + """The ``now`` argument decides which day the credit is compared against.""" write_ledger(earner, [credit(earner, _NOW - timedelta(hours=1))]) assert earned_today(earner, now=_NOW) is True assert earned_today(earner, now=_NOW - timedelta(hours=2)) is False @@ -162,85 +184,7 @@ class TestEarnedToday: def test_a_ledger_outside_ledger_home_is_never_read( self, tmp_path: Path, key: Path, earner: earned_time.Earner ) -> None: + """A ledger placed outside LEDGER_HOME is ignored and never counts as earned.""" stray = tmp_path / "ledger.json" stray.write_text(json.dumps({"entries": [credit(earner)]})) assert earned_today(earner) is not True - - -class TestCannotCheck: - """Every unreadable state is None -- never a confident False -- and logged.""" - - @_LEDGERED - def test_missing_key( - self, - tmp_path: Path, - earner: earned_time.Earner, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """The key is checked first: without it even "no ledger" is unknown.""" - monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key") - with caplog.at_level(logging.WARNING): - assert earned_today(earner) is None - assert "Cannot read the integrity key" in caplog.text - - def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: - write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)]) - with signing_key(tmp_path, b" \n"), caplog.at_level(logging.WARNING): - assert earned_today(earned_time.LEETCODE) is None - assert "is empty" in caplog.text - - def test_missing_leetcode_ledger_is_unknown( - self, key: Path, caplog: pytest.LogCaptureFixture - ) -> None: - with caplog.at_level(logging.WARNING): - assert earned_today(earned_time.LEETCODE) is None - assert "Cannot read the LeetCode ledger" in caplog.text - - def test_missing_reading_ledger_is_a_plain_no(self, key: Path) -> None: - """book-guard never ran: no reading, but no fault either.""" - assert earned_today(earned_time.READING) is False - - @_LEDGERED - def test_corrupt_ledger( - self, - key: Path, - earner: earned_time.Earner, - caplog: pytest.LogCaptureFixture, - ) -> None: - path = ledger_file(earner) - path.parent.mkdir(parents=True) - path.write_text("{not json") - with caplog.at_level(logging.WARNING): - assert earned_today(earner) is None - assert "not valid JSON" in caplog.text - - -@pytest.mark.usefixtures("leetcode_gate_off") -class TestFlatAnswers: - def test_every_flat_earner_answers_by_name(self, key: Path) -> None: - write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)]) - nos = dict.fromkeys((e.name for e in flat_earners()), False) - assert flat_answers() == {**nos, "leetcode": True} - - def test_an_unknown_answer_is_none_and_warned( - self, - tmp_path: Path, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key") - with caplog.at_level(logging.WARNING): - assert flat_answers() == dict.fromkeys(e.name for e in flat_earners()) - for earner in flat_earners(): - assert f"{earner.label} state could not be checked" in caplog.text - - def test_a_no_is_not_warned( - self, key: Path, caplog: pytest.LogCaptureFixture - ) -> None: - write_ledger(earned_time.LEETCODE, []) - with caplog.at_level(logging.WARNING): - assert flat_answers() == dict.fromkeys( - (e.name for e in flat_earners()), False - ) - assert "could not be checked" not in caplog.text diff --git a/screen_locker/tests/test_earned_part2.py b/screen_locker/tests/test_earned_part2.py new file mode 100644 index 0000000..f072fb8 --- /dev/null +++ b/screen_locker/tests/test_earned_part2.py @@ -0,0 +1,118 @@ +"""Tests for ``_earned``: an unreadable ledger or key fails closed to ``None``. + +Companion to ``test_earned``; pins the cannot-check states and ``flat_answers``. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import earned_time +import pytest + +from screen_locker import _earned +from screen_locker._earned import earned_today, flat_answers, flat_earners, ledger_file +from screen_locker.tests._earned_fixtures import credit, signing_key, write_ledger + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_LEDGERED = pytest.mark.parametrize( + "earner", [earned_time.LEETCODE, earned_time.READING], ids=lambda e: e.name +) + + +@pytest.fixture(name="key") +def key_fixture(tmp_path: Path) -> Iterator[Path]: + """Install a valid HMAC signing key under the redirected key path.""" + with signing_key(tmp_path) as path: + yield path + + +class TestCannotCheck: + """Every unreadable state is None -- never a confident False -- and logged.""" + + @_LEDGERED + def test_missing_key( + self, + tmp_path: Path, + earner: earned_time.Earner, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The key is checked first: without it even "no ledger" is unknown.""" + monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key") + with caplog.at_level(logging.WARNING): + assert earned_today(earner) is None + assert "Cannot read the integrity key" in caplog.text + + def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """A whitespace-only key file is unreadable: the answer is None and a warning is logged.""" + write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)]) + with signing_key(tmp_path, b" \n"), caplog.at_level(logging.WARNING): + assert earned_today(earned_time.LEETCODE) is None + assert "is empty" in caplog.text + + def test_missing_leetcode_ledger_is_unknown( + self, key: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A missing LeetCode ledger is None (cannot check) and warned, not a plain no.""" + with caplog.at_level(logging.WARNING): + assert earned_today(earned_time.LEETCODE) is None + assert "Cannot read the LeetCode ledger" in caplog.text + + def test_missing_reading_ledger_is_a_plain_no(self, key: Path) -> None: + """book-guard never ran: no reading, but no fault either.""" + assert earned_today(earned_time.READING) is False + + @_LEDGERED + def test_corrupt_ledger( + self, + key: Path, + earner: earned_time.Earner, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A ledger that is not valid JSON yields None and a warning, never False.""" + path = ledger_file(earner) + path.parent.mkdir(parents=True) + path.write_text("{not json") + with caplog.at_level(logging.WARNING): + assert earned_today(earner) is None + assert "not valid JSON" in caplog.text + + +@pytest.mark.usefixtures("leetcode_gate_off") +class TestFlatAnswers: + """Groups the per-earner answer map built by ``flat_answers``.""" + + def test_every_flat_earner_answers_by_name(self, key: Path) -> None: + """Every flat earner gets an entry by name, True only for the one with a credit today.""" + write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)]) + nos = dict.fromkeys((e.name for e in flat_earners()), False) + assert flat_answers() == {**nos, "leetcode": True} + + def test_an_unknown_answer_is_none_and_warned( + self, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An unreadable key makes every answer None and warns once per earner.""" + monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key") + with caplog.at_level(logging.WARNING): + assert flat_answers() == dict.fromkeys(e.name for e in flat_earners()) + for earner in flat_earners(): + assert f"{earner.label} state could not be checked" in caplog.text + + def test_a_no_is_not_warned( + self, key: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """A definite no (empty ledger) produces no could-not-be-checked warning.""" + write_ledger(earned_time.LEETCODE, []) + with caplog.at_level(logging.WARNING): + assert flat_answers() == dict.fromkeys( + (e.name for e in flat_earners()), False + ) + assert "could not be checked" not in caplog.text diff --git a/screen_locker/tests/test_first_credits.py b/screen_locker/tests/test_first_credits.py index a367c4a..ffe1e49 100644 --- a/screen_locker/tests/test_first_credits.py +++ b/screen_locker/tests/test_first_credits.py @@ -92,6 +92,8 @@ def _paid(day: date) -> None: @pytest.mark.usefixtures("today", "piano") class TestTheMap: + """first_credits maps penalised readers to the day they first paid out.""" + def test_each_penalised_reader_maps_to_its_first_credit(self) -> None: """A bonus, a ledger-less and a matcher-less earner are left out.""" _paid(_DAY - _ONE) @@ -106,14 +108,17 @@ class TestTheMap: assert first_credits(earners, _DAY) == {"piano": _DAY - _ONE, "never": None} def test_a_future_day_is_resolved_too(self) -> None: + """A day after today is resolved as well, giving no first credit yet.""" assert first_credits((PIANO,), _DAY + _ONE) == {"piano": None} def test_history_is_never_re_resolved(self) -> None: + """A past day returns None so history is priced on penalty_from alone.""" assert first_credits((PIANO,), _DAY - _ONE) is None def test_an_earned_time_without_maturity_says_so( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: + """An earned_time without maturity yields None and warns 'no maturity'.""" monkeypatch.delattr(earned_time, "maturity") _earned._warn_no_maturity.cache_clear() with caplog.at_level(logging.WARNING): @@ -151,7 +156,10 @@ class TestTheBase: @pytest.mark.usefixtures("piano") class TestDerive: + """The derived target honours first-credit maturity.""" + def test_first_credits_reach_the_resolution(self) -> None: + """Supplying first credits lifts the penalty from the derived base.""" answers = {e.name: False for e in registry(_DAY)} lenient = derive(DayInputs(_DAY, answers, first_credits={"piano": None})) strict = derive(DayInputs(_DAY, answers)) diff --git a/screen_locker/tests/test_gate_bonus.py b/screen_locker/tests/test_gate_bonus.py index 192c43a..8154de9 100644 --- a/screen_locker/tests/test_gate_bonus.py +++ b/screen_locker/tests/test_gate_bonus.py @@ -79,17 +79,20 @@ def _minutes(units: int) -> int: @pytest.fixture(autouse=True) def with_blocks(monkeypatch: pytest.MonkeyPatch) -> None: + """Register the stand-in BLOCKS earner in the registries for every test.""" register(monkeypatch, BLOCKS) @pytest.fixture def key(tmp_path: Path) -> Iterator[Path]: + """Provide a throwaway signing key for ledger HMACs.""" with signing_key(tmp_path) as path: yield path @pytest.fixture def flat_no() -> Iterator[None]: + """Make every flat earner answer that it did not earn today.""" with answering({}): yield @@ -103,7 +106,10 @@ def _mixin() -> MagicMock: class TestRegistry: + """Counted gate earners are kept apart from flat earners and the workout.""" + def test_counted_gates_are_apart_from_flat_and_the_workout(self) -> None: + """The counted gate is watched but is neither a flat earner nor the workout.""" assert counted_gate_earners()[-1] is BLOCKS assert earned_time.WORKOUT not in counted_gate_earners() assert BLOCKS not in flat_earners() @@ -113,7 +119,10 @@ class TestRegistry: @pytest.mark.usefixtures("key", "flat_no") class TestGateAnswers: + """gate_answers reports counted units alongside the flat answers.""" + def test_units_alongside_flat_answers(self) -> None: + """gate_answers reports the counted unit total next to the flat answers.""" write_ledger(BLOCKS, [_block(1), _block(2)]) answers = gate_answers() assert answers["blocks"] == 2 @@ -122,6 +131,7 @@ class TestGateAnswers: def test_unreadable_ledger_is_none_and_logged( self, caplog: pytest.LogCaptureFixture ) -> None: + """An unreadable ledger answers None and logs that state could not be checked.""" path = write_ledger(BLOCKS, []) path.write_text("{not json", encoding="utf-8") assert gate_answers(TODAY)["blocks"] is None @@ -130,7 +140,10 @@ class TestGateAnswers: @pytest.mark.usefixtures("key") class TestApplyCountedBonus: + """apply_counted_bonus pays each new unit once and tracks it with a stamp.""" + def test_pays_only_the_new_units(self) -> None: + """Each pass pays only the units not yet stamped, and then nothing more.""" state: dict[str, Any] = {} adjust = MagicMock(return_value=True) write_ledger(BLOCKS, [_block(1), _block(2)]) @@ -149,11 +162,13 @@ class TestApplyCountedBonus: [{"date": "2000-01-01", "units": 9}, {"date": None}, "junk", {"units": "x"}], ) def test_a_stale_or_broken_stamp_is_zero(self, stamp: object) -> None: + """A stale-dated or malformed stamp counts as zero applied units.""" assert not applied_units({"blocks_bonus_units": stamp}, BLOCKS, today_str()) def test_failed_write_keeps_the_stamp( self, caplog: pytest.LogCaptureFixture ) -> None: + """A failed config write leaves the stamp unchanged and logs the failure.""" state: dict[str, Any] = {} write_ledger(BLOCKS, [_block(1)]) adjust = MagicMock(return_value=False) @@ -164,6 +179,7 @@ class TestApplyCountedBonus: def test_unknown_earns_nothing_and_says_so( self, caplog: pytest.LogCaptureFixture ) -> None: + """An unreadable unit count pays nothing and warns it could not be checked.""" write_ledger(BLOCKS, []).write_text("[]", encoding="utf-8") with caplog.at_level(logging.WARNING): assert not apply_counted_bonus({}, MagicMock(), BLOCKS, today_str()) @@ -172,7 +188,10 @@ class TestApplyCountedBonus: @pytest.mark.usefixtures("key", "flat_no") class TestThroughTheShutdownBase: + """Counted units flow through the daily reset and the live bonus pass.""" + def test_reset_stamps_flat_dates_and_counted_units(self) -> None: + """reset_stamps records flat earners by date and counted ones by unit count.""" answers = {"workout": 1, "leetcode": True, "blocks": 2} terms = earned_time.resolve(answers, TODAY, registry(TODAY)).terms stamps = reset_stamps(terms, today_str()) @@ -184,6 +203,7 @@ class TestThroughTheShutdownBase: def test_reset_includes_units_then_live_pass_adds_only_new( self, tmp_path: Path ) -> None: + """The reset includes existing units and a later live pass adds only new ones.""" state = tmp_path / "state.json" write_ledger(BLOCKS, [_block(1)]) mixin = _mixin() @@ -201,6 +221,7 @@ class TestThroughTheShutdownBase: assert units == 2 def test_live_pass_without_new_units_writes_nothing(self, tmp_path: Path) -> None: + """A live pass with no units adjusts nothing and creates no state file.""" state = tmp_path / "state.json" write_ledger(BLOCKS, []) mixin = _mixin() diff --git a/screen_locker/tests/test_gate_bonus_tutor.py b/screen_locker/tests/test_gate_bonus_tutor.py index 9d84284..9a1da9b 100644 --- a/screen_locker/tests/test_gate_bonus_tutor.py +++ b/screen_locker/tests/test_gate_bonus_tutor.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: @pytest.fixture def key(tmp_path: Path) -> Iterator[Path]: + """A signing key under tmp_path so ledger rows verify.""" with signing_key(tmp_path) as path: yield path diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index b385a8a..fe343bf 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -30,18 +30,23 @@ _FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False} def at(hhmm: str, day: date = _DAY) -> float: + """Unix time of ``hhmm`` local on ``day``.""" hours, minutes = map(int, hhmm.split(":")) return datetime.combine(day, time(hours, minutes)).astimezone().timestamp() def log_with(tmp_path: Path, entries: list[dict[str, Any]]) -> Path: + """Write a workout log holding ``entries`` under the test day and return its path.""" log = tmp_path / "log.json" log.write_text(json.dumps({_ISO: entries})) return log class TestWhenTheDayStarted: + """How the first completion of the day is found across the log and the ledgers.""" + def test_the_first_counted_workout_by_its_own_end(self, tmp_path: Path) -> None: + """The first counted workout is timed by its own completion, skipping rest-day rows.""" log = log_with( tmp_path, [ @@ -58,9 +63,11 @@ class TestWhenTheDayStarted: assert workout_done_at(log, _DAY) == at("17:00") def test_no_workout_is_none(self, tmp_path: Path) -> None: + """A day without any workout entry has no completion time.""" assert workout_done_at(log_with(tmp_path, []), _DAY) is None def test_the_earliest_earner_on_the_day_wins(self, tmp_path: Path) -> None: + """The earliest credit among the earners and the log is the start of the day.""" log = log_with( tmp_path, [{"workout_data": {"type": "manual_workout", "end_time": "19:30"}}], @@ -86,6 +93,7 @@ class TestWhenTheDayStarted: assert first_done_at(_DAY, None, rest_day=True) is None def test_without_a_workout_ledger_only_the_log_answers(self) -> None: + """Without a workout ledger only the log can supply a completion time.""" bare = earned_time.Earner( name="workout", label="workout", gaming_minutes=0, shutdown_minutes=0 ) @@ -97,7 +105,10 @@ class TestWhenTheDayStarted: class TestLift: + """Saving, spending and expiring the grace lift.""" + def test_absorb_pays_from_the_lift_first(self) -> None: + """Absorbing a credit spends the saved lift before paying anything new.""" with patch.object(_grace_floor, "today_str", return_value=_ISO): assert plan_absorb(30) == (30, 0) save_lift(45) @@ -105,6 +116,7 @@ class TestLift: assert plan_absorb(60) == (15, 0) def test_yesterdays_lift_is_spent(self) -> None: + """A lift saved on an earlier day no longer counts.""" with patch.object(_grace_floor, "today_str", return_value="2026-10-09"): save_lift(45) with patch.object(_grace_floor, "today_str", return_value=_ISO): @@ -113,6 +125,7 @@ class TestLift: def test_an_unreadable_state_is_no_lift( self, caplog: pytest.LogCaptureFixture ) -> None: + """A corrupt state file means no lift and logs a warning.""" _grace_floor.GRACE_STATE_FILE.write_text("{bad") with caplog.at_level(logging.WARNING): assert plan_absorb(10) == (10, 0) @@ -121,6 +134,7 @@ class TestLift: def test_a_failed_save_is_logged( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A lift that cannot be written is logged as a warning.""" with caplog.at_level(logging.WARNING): save_lift(5, tmp_path / "missing-dir" / "g.json") assert "Could not record the grace lift" in caplog.text diff --git a/screen_locker/tests/test_heat_skip.py b/screen_locker/tests/test_heat_skip.py index 6f6fdf8..5ebb237 100644 --- a/screen_locker/tests/test_heat_skip.py +++ b/screen_locker/tests/test_heat_skip.py @@ -27,6 +27,7 @@ class TestShowHeatSkipDialog: """_show_heat_skip_dialog blocks on mainloop() until a button fires destroy().""" def test_returns_true_when_skip_clicked(self, mock_tk: MagicMock) -> None: + """Clicking Skip workout makes the dialog return True and closes the window.""" host = _HeatSkipHost() def _simulate_click() -> None: @@ -37,6 +38,7 @@ class TestShowHeatSkipDialog: mock_tk.Tk.return_value.destroy.assert_called_once() def test_returns_false_when_declined(self, mock_tk: MagicMock) -> None: + """Declining makes the dialog return False.""" host = _HeatSkipHost() def _simulate_click() -> None: @@ -46,6 +48,7 @@ class TestShowHeatSkipDialog: assert host._show_heat_skip_dialog(35.0) is False def test_dialog_shows_temperature_and_threshold(self, mock_tk: MagicMock) -> None: + """The dialog text shows the rounded temperature and mentions the threshold.""" host = _HeatSkipHost() mock_tk.Tk.return_value.mainloop.side_effect = None host._show_heat_skip_dialog(36.4) @@ -58,6 +61,7 @@ class TestSaveHeatSkipLog: """_save_heat_skip_log builds workout_data and delegates to save_workout_log.""" def test_saves_expected_workout_data(self) -> None: + """A heat skip is saved as a heat_skip entry with the rounded temperature and a city.""" host = _HeatSkipHost() host._save_heat_skip_log(34.6) assert host.workout_data["type"] == "heat_skip" diff --git a/screen_locker/tests/test_instance.py b/screen_locker/tests/test_instance.py index 5ffe0b6..6039b93 100644 --- a/screen_locker/tests/test_instance.py +++ b/screen_locker/tests/test_instance.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: def test_the_first_run_takes_the_lock(tmp_path: Path) -> None: + """The first process to ask gets the instance lock.""" lock = _instance.acquire(tmp_path / "instance.lock") assert lock is not None @@ -38,6 +39,7 @@ def test_a_second_run_stands_down( def test_the_lock_is_retakeable_after_release(tmp_path: Path) -> None: + """A released lock can be acquired again.""" path = tmp_path / "instance.lock" first = _instance.acquire(path) assert first is not None @@ -52,6 +54,7 @@ def test_the_lock_is_retakeable_after_release(tmp_path: Path) -> None: def test_an_unopenable_path_reports_failure_rather_than_raising( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A lock path that cannot be opened returns None instead of raising.""" blocker = tmp_path / "blocker" blocker.write_text("file", encoding="utf-8") @@ -60,6 +63,7 @@ def test_an_unopenable_path_reports_failure_rather_than_raising( def test_releasing_twice_is_safe(tmp_path: Path) -> None: + """Calling release a second time does not raise.""" lock = _instance.acquire(tmp_path / "instance.lock") assert lock is not None @@ -68,6 +72,7 @@ def test_releasing_twice_is_safe(tmp_path: Path) -> None: def test_the_lock_file_records_the_pid(tmp_path: Path) -> None: + """The lock file contains the holder's PID.""" path = tmp_path / "instance.lock" lock = _instance.acquire(path) assert lock is not None @@ -79,11 +84,15 @@ def test_the_lock_file_records_the_pid(tmp_path: Path) -> None: def test_a_close_failure_is_reported_not_raised( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A failing close during release is logged as a warning, not raised.""" lock = _instance.acquire(tmp_path / "instance.lock") assert lock is not None class Broken: + """A file handle stand-in whose close always fails.""" + def close(self) -> None: + """Raise OSError to simulate a handle that cannot be closed.""" message = "cannot close" raise OSError(message) diff --git a/screen_locker/tests/test_late_credit.py b/screen_locker/tests/test_late_credit.py index 88107cf..d82ad2c 100644 --- a/screen_locker/tests/test_late_credit.py +++ b/screen_locker/tests/test_late_credit.py @@ -39,10 +39,12 @@ def at(hhmm: str, day: date = _DAY) -> float: def host_workout_minutes() -> int: + """The reward in minutes that the workout earner pays on the test day.""" return first_minutes(earned_time.WORKOUT, _DAY) def m(hhmm: str) -> int: + """Convert an HH:MM string into minutes after midnight.""" hour, minute = (int(p) for p in hhmm.split(":")) return hour * 60 + minute @@ -67,6 +69,7 @@ class Host(ShutdownMixin): @property def tonight(self) -> int: + """Tonight's shutdown time in minutes (the Thu-Sun band).""" return self.config[1] @@ -82,12 +85,16 @@ def _on_the_day() -> Iterator[Any]: class TestLateCredit: + """Credits timed after tonight's shutdown restart the clock from their own time.""" + def test_a_credit_before_the_shutdown_adds_as_before(self) -> None: + """A credit before the shutdown simply adds its reward to tonight.""" host = Host("20:00") assert host._adjust_shutdown_time_by(60, at=at("18:30")) is True assert host.tonight == m("21:00") def test_a_credit_after_the_shutdown_counts_from_its_own_time(self) -> None: + """A late credit sets tonight to its own time plus the reward, in both bands.""" host = Host("18:00") assert host._adjust_shutdown_time_by(60, at=at("19:00")) is True assert host.tonight == m("20:00") @@ -100,11 +107,13 @@ class TestLateCredit: assert host.tonight == m("20:00") def test_capped_at_the_ceiling(self) -> None: + """A late credit never pushes tonight past the 23:00 ceiling.""" host = Host("22:30") host._adjust_shutdown_time_by(120, at=at("22:45")) assert host.tonight == m("23:00") def test_after_the_ceiling_nothing_moves_past_it(self) -> None: + """A credit after the ceiling leaves tonight at 23:00.""" host = Host("23:00") host._adjust_shutdown_time_by(60, at=at("23:10")) assert host.tonight == m("23:00") @@ -116,22 +125,26 @@ class TestLateCredit: assert host.tonight == m("18:00") + host_workout_minutes() def test_a_late_workout_counts_from_its_end(self) -> None: + """A workout finishing after the shutdown counts from its end time.""" host = Host("18:00") host._adjust_shutdown_time_later(at=at("19:00")) assert host.tonight == min(m("23:00"), m("19:00") + host_workout_minutes()) def test_a_credit_timed_on_another_day_is_never_late(self) -> None: + """A credit stamped on a different day is treated as not late.""" host = Host("18:00") host._adjust_shutdown_time_by(60, at=at("19:00", date(2026, 10, 16))) assert host.tonight == m("19:00") def test_no_time_means_now(self) -> None: + """Without an explicit time the credit is anchored on the current time.""" host = Host("18:00") with patch.object(_late_credit, "_now", return_value=at("19:30")): host._adjust_shutdown_time_by(60) assert host.tonight == m("20:30") def test_now_is_the_wall_clock(self) -> None: + """The default clock reads the real wall-clock time.""" with patch.object(_late_credit.time, "time", return_value=123.0): assert _real_now() == 123.0 @@ -144,19 +157,24 @@ class TestLateCredit: assert plan_absorb(60) == (60, 0) def test_a_failed_write_keeps_the_lift(self) -> None: + """If writing the config fails the saved lift is kept.""" save_lift(30) host = Host("18:00", ok=False) assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False assert plan_absorb(0) == (0, 30) def test_unreadable_config_fails(self) -> None: + """An unreadable shutdown config makes the late adjustment fail.""" host = Host("18:00") with patch.object(Host, "_read_shutdown_config", return_value=None): assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False class TestLateOnASickDay: + """Late-credit behaviour on a sick day, where the sick target is the floor.""" + def test_late_after_the_sick_target(self) -> None: + """A credit after the sick target counts from its own time.""" mark_sick(_DAY) host = Host("20:00") with patch.object(_sick_floor, "sick_target", return_value=m("20:00")): @@ -164,6 +182,7 @@ class TestLateOnASickDay: assert host.tonight == m("22:00") def test_not_late_raises_to_the_target_only(self) -> None: + """A not-late credit on a sick day only raises tonight to the sick target.""" mark_sick(_DAY) host = Host("21:00") with patch.object(_sick_floor, "sick_target", return_value=m("22:00")): diff --git a/screen_locker/tests/test_leetcode_gate_off.py b/screen_locker/tests/test_leetcode_gate_off.py index 77d7855..6dccda5 100644 --- a/screen_locker/tests/test_leetcode_gate_off.py +++ b/screen_locker/tests/test_leetcode_gate_off.py @@ -42,16 +42,19 @@ def pools_fixture(monkeypatch: pytest.MonkeyPatch) -> dict[str, bool]: def test_gate_off_follows_the_pools( pools: dict[str, bool], *, free: bool, sick_off: bool, expected: bool ) -> None: + """The LeetCode gate is off on a free day or a sick day that drops it.""" pools.update(free=free, sick_off=sick_off) assert _gate_off.gate_off(earned_time.LEETCODE, SAT) is expected def test_only_leetcode_has_a_gate_to_switch_off(pools: dict[str, bool]) -> None: + """Earners other than LeetCode never report their gate as off.""" pools.update(free=True, sick_off=True) assert _gate_off.gate_off(earned_time.READING, SAT) is False def test_every_ledger_read_passes_it(pools: dict[str, bool]) -> None: + """Every earned_time ledger read receives the live gate_off flag.""" pools.update(free=True) leet = earned_time.LEETCODE with ( diff --git a/screen_locker/tests/test_local_day_key.py b/screen_locker/tests/test_local_day_key.py index a8954c1..8e55f8a 100644 --- a/screen_locker/tests/test_local_day_key.py +++ b/screen_locker/tests/test_local_day_key.py @@ -49,6 +49,7 @@ def warsaw_after_midnight_fixture(monkeypatch: pytest.MonkeyPatch) -> Iterator[N @staticmethod def now(tz: tzinfo | None = None) -> datetime: + """Return the frozen instant converted to the requested timezone.""" return JUST_AFTER_MIDNIGHT_UTC.astimezone(tz or UTC) monkeypatch.setattr(_day, "datetime", _FrozenClock) @@ -61,11 +62,13 @@ class TestTodayStr: """``_day.today_str`` is the single source of "what day is it".""" def test_uses_the_local_calendar_day(self, warsaw_after_midnight: None) -> None: + """today_str answers the local day, not the UTC day.""" assert _day.today_str() == LOCAL_DAY def test_explicit_instant_is_converted_to_local( self, warsaw_after_midnight: None ) -> None: + """An explicit UTC instant is converted to its local calendar day.""" assert _day.today_str(JUST_AFTER_MIDNIGHT_UTC) == LOCAL_DAY @@ -75,6 +78,7 @@ class TestAfterMidnightLog: def test_write_and_lock_check_agree( self, tmp_path: Path, warsaw_after_midnight: None ) -> None: + """A workout written just after local midnight is found by the lock check.""" log_file = tmp_path / "log.json" write_signed_entry( log_file, @@ -129,6 +133,7 @@ class TestCrossDayDedup: def test_same_workout_id_on_another_day_is_not_appended( self, tmp_path: Path ) -> None: + """A workout id already logged on another day is not appended again.""" log_file = tmp_path / "log.json" data = {"type": "manual_workout", "workout_id": "manual:2026-09-12T14:30"} assert write_signed_entry(log_file, LOCAL_DAY, data).appended is True diff --git a/screen_locker/tests/test_manual_future.py b/screen_locker/tests/test_manual_future.py index c9c9628..09439a8 100644 --- a/screen_locker/tests/test_manual_future.py +++ b/screen_locker/tests/test_manual_future.py @@ -49,12 +49,14 @@ class TestParseWorkoutDate: """Strict YYYY-MM-DD parsing.""" def test_parses_an_iso_date(self) -> None: + """Surrounding whitespace is stripped before an ISO date is parsed.""" assert parse_workout_date(" 2026-10-17 ") == date(2026, 10, 17) @pytest.mark.parametrize("bad", ["", "tomorrow", "2026-13-01", "20261017"]) def test_rejects_and_warns( self, bad: str, caplog: pytest.LogCaptureFixture ) -> None: + """A malformed date yields None and logs that it is not YYYY-MM-DD.""" with caplog.at_level(logging.WARNING): assert parse_workout_date(bad) is None assert "not YYYY-MM-DD" in caplog.text @@ -64,15 +66,18 @@ class TestIsFutureDate: """Strictly after the reference day, valid dates only.""" def test_tomorrow_is_future_and_today_is_not(self) -> None: + """Only days strictly after the reference day count as future.""" assert is_future_date(_plus(1), today=_TODAY) is True assert is_future_date(_TODAY, today=_TODAY) is False assert is_future_date(_plus(-1), today=_TODAY) is False def test_garbage_is_not_future(self) -> None: + """An unparsable date or reference day is never reported as future.""" assert is_future_date("nope", today=_TODAY) is False assert is_future_date(_plus(1), today="nope") is False def test_defaults_to_the_real_local_day(self) -> None: + """Without an explicit reference day the real local date is used.""" assert is_future_date("2999-01-01") is True @@ -81,29 +86,35 @@ class TestValidateWorkoutDate: @pytest.mark.parametrize("ahead", [0, 1, 7]) def test_accepts_today_through_plus_seven(self, ahead: int) -> None: + """Dates from today up to seven days ahead pass validation.""" assert validate_workout_date(_plus(ahead), today=_TODAY) is None def test_rejects_the_past(self) -> None: + """A date before today is refused with a message naming the past.""" error = validate_workout_date(_plus(-1), today=_TODAY) assert error is not None assert "past" in error def test_rejects_eight_days_ahead(self) -> None: + """A date eight days ahead is refused with the day count in the message.""" error = validate_workout_date(_plus(8), today=_TODAY) assert error is not None assert "8 days ahead" in error def test_rejects_a_garbled_date(self) -> None: + """A non-date string gets the YYYY-MM-DD format error.""" assert validate_workout_date("soon", today=_TODAY) == ( "Workout date must be YYYY-MM-DD" ) def test_reports_an_unusable_reference_day(self) -> None: + """A garbled reference day is reported by value instead of crashing.""" error = validate_workout_date(_TODAY, today="garbled") assert error is not None assert "'garbled'" in error def test_defaults_to_the_real_local_day(self) -> None: + """Validation without a reference day compares against the real local date.""" assert validate_workout_date("1999-01-01") is not None @@ -113,6 +124,7 @@ class TestBudgetUsage: def test_counts_logged_and_pending_in_the_fullest_window( self, tmp_path: Path ) -> None: + """Logged and pending workouts are both counted in the fullest window.""" log_file = _log(tmp_path, _plus(-2)) usage = budget_usage_for_date(log_file, _plus(1), [_plus(2)]) assert usage == BudgetUsage(week=2, month=2) @@ -120,20 +132,24 @@ class TestBudgetUsage: assert usage.exhausted is True def test_an_empty_log_is_not_exhausted(self, tmp_path: Path) -> None: + """A missing log gives zero usage that is not exhausted.""" usage = budget_usage_for_date(tmp_path / "none.json", _TODAY) assert usage == BudgetUsage(week=0, month=0) assert usage is not None assert usage.exhausted is False def test_the_monthly_cap_alone_exhausts(self) -> None: + """Reaching the monthly cap alone marks the budget exhausted.""" assert BudgetUsage(week=0, month=10).exhausted is True assert BudgetUsage(week=1, month=9).exhausted is False def test_an_unparsable_pending_date_is_ignored(self, tmp_path: Path) -> None: + """A garbled pending date does not count toward usage.""" usage = budget_usage_for_date(tmp_path / "none.json", _TODAY, ["garbled"]) assert usage == BudgetUsage(week=0, month=0) def test_a_bad_date_has_no_usage(self, tmp_path: Path) -> None: + """An invalid workout date yields no usage at all (None).""" assert budget_usage_for_date(tmp_path / "none.json", "x") is None @@ -141,16 +157,19 @@ class TestBudgetErrorForDate: """Admission is decided per workout date, not against today's window.""" def test_a_full_week_refuses_a_day_inside_it(self, tmp_path: Path) -> None: + """A day inside an already full 7-day window is refused with the cap in the message.""" log_file = _log(tmp_path, _plus(-6), _plus(-5)) error = budget_error_for_date(log_file, _TODAY) assert error is not None assert "max 2 per 7 days" in error def test_the_same_week_still_takes_a_day_outside_it(self, tmp_path: Path) -> None: + """A day outside the full window is still accepted.""" log_file = _log(tmp_path, _plus(-6), _plus(-5)) assert budget_error_for_date(log_file, _plus(1)) is None def test_pending_future_entries_hold_their_slot(self, tmp_path: Path) -> None: + """Pending future workouts occupy budget slots near their dates only.""" pending = [_plus(1), _plus(2)] error = budget_error_for_date(tmp_path / "none.json", _plus(3), pending) assert error is not None @@ -161,6 +180,7 @@ class TestBudgetErrorForDate: ) -> None: # With the real caps a 7-day window always trips first (ten entries in # 30 days force two per week), so isolate the 30-day window. + """The 30-day window cap refuses a date when it alone is full.""" monkeypatch.setattr(_manual_future, "_WINDOWS", ((30, 2),)) days = [_plus(-10), _plus(10)] error = budget_error_for_date(tmp_path / "none.json", _TODAY, days) @@ -168,6 +188,7 @@ class TestBudgetErrorForDate: assert "per 30 days" in error def test_a_bad_date_is_refused(self, tmp_path: Path) -> None: + """An unparsable date is refused with the YYYY-MM-DD message.""" error = budget_error_for_date(tmp_path / "none.json", "later") assert error is not None assert "YYYY-MM-DD" in error @@ -177,23 +198,28 @@ class TestBudgetClosedForAllDays: """The button hides only when no day today..+7 has room.""" def test_open_when_empty(self, tmp_path: Path) -> None: + """With no workouts logged the budget is open.""" assert not budget_closed_for_all_days(tmp_path / "n.json", today=_TODAY) def test_a_full_today_does_not_close_next_week(self, tmp_path: Path) -> None: + """A full window around today still leaves later days open.""" log_file = _log(tmp_path, _plus(-1), _TODAY) assert not budget_closed_for_all_days(log_file, today=_TODAY) def test_closed_when_every_day_in_range_is_full(self, tmp_path: Path) -> None: # Two pending on each side keep every window in today..+7 at the cap. + """The budget is closed once every day from today to +7 is at the cap.""" pending = [_plus(-3), _plus(-2), _plus(4), _plus(5)] assert budget_closed_for_all_days(tmp_path / "n.json", pending, today=_TODAY) def test_a_garbled_today_counts_as_closed( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """An unparsable today fails closed and logs a warning.""" with caplog.at_level(logging.WARNING): assert budget_closed_for_all_days(tmp_path / "n.json", today="??") assert "budget treated as closed" in caplog.text def test_defaults_to_the_real_local_day(self, tmp_path: Path) -> None: + """Without an explicit today the real local date is used and the budget is open.""" assert not budget_closed_for_all_days(tmp_path / "n.json") diff --git a/screen_locker/tests/test_manual_future_wiring.py b/screen_locker/tests/test_manual_future_wiring.py index dbb8293..82d4d61 100644 --- a/screen_locker/tests/test_manual_future_wiring.py +++ b/screen_locker/tests/test_manual_future_wiring.py @@ -39,12 +39,14 @@ class TestPushPublishesThePendingLedger: def test_a_pending_entry_is_published_even_with_an_empty_log( self, tmp_path: Path ) -> None: + """A pending ledger entry is published even when the workout log is empty.""" add_pending(_RID, dict(_PAYLOAD), "pc-1") records = records_from_workout_log(tmp_path / "log.json") assert list(records) == [_RID] assert records[_RID].fields["payload"][0] == _PAYLOAD def test_the_logs_own_copy_wins_once_ingested(self, tmp_path: Path) -> None: + """Once a workout is in the log, its payload replaces the pending ledger copy.""" log_file = tmp_path / "log.json" _write_manual(log_file, _RID, "2026-10-17") add_pending(_RID, {**_PAYLOAD, "marker": "ledger"}, "pc-1") @@ -52,11 +54,13 @@ class TestPushPublishesThePendingLedger: assert "marker" not in payload def test_a_tombstone_beats_the_pending_entry(self, tmp_path: Path) -> None: + """A tombstone marks a pending entry deleted instead of publishing it.""" add_pending(_RID, dict(_PAYLOAD), "pc-1") add_tombstone(_RID, "pc-1", "deleted") assert records_from_workout_log(tmp_path / "log.json")[_RID].deleted def test_a_push_drops_entries_the_merge_shows_deleted(self, tmp_path: Path) -> None: + """A pending entry the merge reports deleted is dropped from the ledger after a push.""" add_pending(_RID, dict(_PAYLOAD), "pc-1") merged = {_RID: Record(id=_RID, fields={}, deleted=True)} with ( @@ -70,6 +74,7 @@ class TestPushPublishesThePendingLedger: assert local_pending_records() == [] def test_a_push_keeps_a_live_pending_entry(self, tmp_path: Path) -> None: + """A pending entry that is still live survives a push.""" add_pending(_RID, dict(_PAYLOAD), "pc-1") merged = {_RID: records_from_workout_log(tmp_path / "log.json")[_RID]} with ( @@ -88,6 +93,7 @@ class TestSyncPassReadsTheLedger: def test_ledger_entries_join_the_pull_without_duplicating_it( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Ledger entries are added to the pulled records without repeating one already pulled.""" locker = create_locker(mock_tk, tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1") add_pending( @@ -111,6 +117,7 @@ class TestSyncPassReadsTheLedger: def test_due_local_entries_are_ingested_with_the_credit_callback( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Due local entries are ingested with the credit callback and state is cleared.""" locker = create_locker(mock_tk, tmp_path) locker.workout_data = {"stale": "state"} add_pending(_RID, dict(_PAYLOAD), "pc-1") @@ -131,6 +138,7 @@ class TestPokeStartTheDay: def test_the_reset_is_followed_by_the_local_ingest( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Starting the day runs the base reset first and then the due-entry ingest.""" calls: list[str] = [] def headless(locker_cls: type[PokeLocker]) -> PokeLocker: @@ -161,6 +169,7 @@ class TestManualCreditDates: def test_unparsable_day_keys_are_skipped( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A log key that is not a date is skipped with a warning.""" log_file = tmp_path / "log.json" _write_manual(log_file, "manual:2026-10-10T18:00", "2026-10-10") raw = json.loads(log_file.read_text()) diff --git a/screen_locker/tests/test_manual_pending.py b/screen_locker/tests/test_manual_pending.py index 503baee..6c3a9cc 100644 --- a/screen_locker/tests/test_manual_pending.py +++ b/screen_locker/tests/test_manual_pending.py @@ -47,14 +47,17 @@ class TestLedgerFile: """Reading and writing the ledger is loud about damage.""" def test_missing_ledger_is_empty(self, tmp_path: Path) -> None: + """A ledger file that does not exist reads as no pending records.""" assert local_pending_records(_ledger(tmp_path)) == [] def test_defaults_to_the_isolated_module_path(self) -> None: + """With no path given the module's own (test-isolated) ledger file is used.""" assert _manual_pending._ledger_path(None) == _manual_pending.MANUAL_PENDING_FILE def test_corrupt_ledger_is_empty_and_warned( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Unparsable JSON yields no records and logs an 'unreadable' warning.""" path = _ledger(tmp_path) path.write_text("{not json") with caplog.at_level(logging.WARNING): @@ -64,6 +67,7 @@ class TestLedgerFile: def test_non_object_ledger_is_ignored_and_warned( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A ledger whose top level is not a JSON object is ignored with a warning.""" path = _ledger(tmp_path) path.write_text("[1, 2]") with caplog.at_level(logging.WARNING): @@ -71,6 +75,7 @@ class TestLedgerFile: assert "not a JSON object" in caplog.text def test_non_dict_entries_are_dropped(self, tmp_path: Path) -> None: + """Junk entries that are not dicts are skipped while valid ones survive.""" path = _ledger(tmp_path) path.write_text(json.dumps({"a": "junk", _RID: {"payload": _PAYLOAD}})) assert local_pending_records(path) == [(_RID, _PAYLOAD)] @@ -80,6 +85,7 @@ class TestAddPending: """Entries are stored once with a stable, minted-once HLC.""" def test_stores_payload_and_hlc(self, tmp_path: Path) -> None: + """A new entry is stored with its payload and a parseable HLC.""" path = _ledger(tmp_path) assert add_pending(_RID, dict(_PAYLOAD), "pc-1", path) is True stored = json.loads(path.read_text())[_RID] @@ -87,6 +93,7 @@ class TestAddPending: assert Hlc.from_str(stored["hlc"]) is not None def test_a_second_add_is_a_no_op_that_keeps_the_hlc(self, tmp_path: Path) -> None: + """Adding an existing id returns False and leaves the stored entry untouched.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) first = path.read_text() @@ -98,17 +105,20 @@ class TestLocalPendingRecords: """What ingest reads when Firebase is down on the day.""" def test_returns_id_and_payload(self, tmp_path: Path) -> None: + """A stored entry comes back as an (id, payload) pair.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) assert local_pending_records(path) == [(_RID, _PAYLOAD)] def test_a_tombstoned_entry_is_not_returned(self, tmp_path: Path) -> None: + """An entry tombstoned on this PC is hidden from ingest.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) add_tombstone(_RID, "pc-1", "deleted") assert local_pending_records(path) == [] def test_an_entry_without_a_payload_is_skipped(self, tmp_path: Path) -> None: + """An entry that has no payload field is not returned.""" path = _ledger(tmp_path) path.write_text(json.dumps({_RID: {"hlc": "x"}})) assert local_pending_records(path) == [] @@ -118,6 +128,7 @@ class TestPendingLedgerRecords: """What ``_manual_push`` re-publishes every tick.""" def test_builds_a_record_with_the_stored_hlc(self, tmp_path: Path) -> None: + """The republished record carries the payload and the originally minted HLC.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) records = pending_ledger_records(path) @@ -128,6 +139,7 @@ class TestPendingLedgerRecords: def test_bad_hlc_is_skipped_and_warned( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """An unparsable HLC drops the record and warns that it was NOT published.""" path = _ledger(tmp_path) path.write_text(json.dumps({_RID: {"payload": _PAYLOAD, "hlc": "garbage"}})) with caplog.at_level(logging.WARNING): @@ -135,6 +147,7 @@ class TestPendingLedgerRecords: assert "NOT published" in caplog.text def test_non_dict_payload_is_skipped(self, tmp_path: Path) -> None: + """An entry whose payload is not a dict is not republished.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) raw = json.loads(path.read_text()) @@ -147,9 +160,11 @@ class TestSettlePending: """Entries leave the ledger once logged or deleted.""" def test_empty_ledger_settles_nothing(self, tmp_path: Path) -> None: + """Settling an empty ledger removes nothing.""" assert settle_pending(tmp_path / "log.json", path=_ledger(tmp_path)) == [] def test_an_entry_already_in_the_log_is_dropped(self, tmp_path: Path) -> None: + """An entry whose workout already sits in the log leaves the ledger.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) log_file = _log_with(tmp_path, _RID, "2026-10-17") @@ -157,6 +172,7 @@ class TestSettlePending: assert json.loads(path.read_text()) == {} def test_a_synced_copy_in_the_log_also_settles_it(self, tmp_path: Path) -> None: + """A log entry that carries the id as sync_record_id also settles it.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) log_file = tmp_path / "log.json" @@ -165,17 +181,20 @@ class TestSettlePending: assert settle_pending(log_file, path=path) == [_RID] def test_a_deleted_id_from_the_merge_is_dropped(self, tmp_path: Path) -> None: + """An id reported deleted by the merge is removed from the ledger.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) assert settle_pending(tmp_path / "none.json", [_RID], path) == [_RID] def test_a_local_tombstone_is_dropped(self, tmp_path: Path) -> None: + """A locally tombstoned id is removed from the ledger.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) add_tombstone(_RID, "pc-1", "deleted") assert settle_pending(tmp_path / "none.json", path=path) == [_RID] def test_a_live_future_entry_stays(self, tmp_path: Path) -> None: + """An entry that is neither logged nor deleted stays pending.""" path = _ledger(tmp_path) add_pending(_RID, dict(_PAYLOAD), "pc-1", path) assert settle_pending(tmp_path / "none.json", path=path) == [] @@ -186,6 +205,7 @@ class TestPendingFutureDates: """Which records still hold a future slot.""" def test_only_manual_records_after_today_count(self, tmp_path: Path) -> None: + """Only well-formed manual workouts dated after today hold a future slot.""" records = [ ("future", {**_PAYLOAD, "date": "2026-10-18"}), ("today", {**_PAYLOAD, "date": "2026-10-16"}), @@ -200,16 +220,19 @@ class TestPendingFutureDates: assert found == {"future": "2026-10-18"} def test_an_already_logged_id_is_not_pending(self, tmp_path: Path) -> None: + """A record whose id is already in the log is not counted as pending.""" log_file = _log_with(tmp_path, _RID, "2026-10-17") found = pending_future_dates(log_file, [(_RID, _PAYLOAD)], today="2026-10-16") assert not found def test_defaults_to_the_real_local_day(self, tmp_path: Path) -> None: + """Without an explicit today the real local day is the cut-off.""" payload = {**_PAYLOAD, "date": "2999-01-01"} found = pending_future_dates(tmp_path / "none.json", [("far", payload)]) assert found == {"far": "2999-01-01"} def test_an_entry_with_no_derivable_id_is_ignored(self, tmp_path: Path) -> None: + """A log entry from which no id can be derived does not hide a pending record.""" log_file = tmp_path / "log.json" log_file.write_text(json.dumps({"2026-10-10": [{"workout_data": {}}]})) found = pending_future_dates(log_file, [(_RID, _PAYLOAD)], today="2026-10-16") diff --git a/screen_locker/tests/test_manual_rekey_dedup.py b/screen_locker/tests/test_manual_rekey_dedup.py index d39b74f..2858121 100644 --- a/screen_locker/tests/test_manual_rekey_dedup.py +++ b/screen_locker/tests/test_manual_rekey_dedup.py @@ -81,6 +81,7 @@ class TestSyncedTwinIsNotReIngested: def test_rekeyed_entry_is_recognised_by_its_workout_id( self, tmp_path: Path ) -> None: + """The PC's own published twin is matched by workout id and not ingested again.""" log_file = tmp_path / "log.json" _log_after_rekey(log_file) diff --git a/screen_locker/tests/test_manual_schedule.py b/screen_locker/tests/test_manual_schedule.py index 1c22b9e..4b6e29a 100644 --- a/screen_locker/tests/test_manual_schedule.py +++ b/screen_locker/tests/test_manual_schedule.py @@ -73,15 +73,18 @@ class TestBudgetError: """Budget sees the Firebase pull AND this PC's ledger.""" def test_open_when_nothing_is_pending(self, stubs: SimpleNamespace) -> None: + """With nothing pending the weekly budget has no error.""" assert budget_error(stubs.log_file, _plus(1)) is None def test_phone_pending_entries_hold_a_slot(self, stubs: SimpleNamespace) -> None: + """Entries pulled from the phone count against the budget.""" for offset in (1, 2): payload = build_sync_payload(_draft(f"1{offset}:00"), _plus(offset)) stubs.pulled.append((f"manual:{_plus(offset)}T1{offset}:00", payload)) assert budget_error(stubs.log_file, _plus(3)) is not None def test_excluding_an_id_frees_its_slot(self, stubs: SimpleNamespace) -> None: + """Excluding a record id frees the slot that record held.""" ids = [] for offset in (1, 2): rid = f"manual:{_plus(offset)}T1{offset}:00" @@ -95,6 +98,7 @@ class TestScheduleFutureWorkout: """Hold a validated draft for a later day and publish it.""" def test_schedules_stores_and_publishes(self, stubs: SimpleNamespace) -> None: + """A valid future draft is stored in the ledger and pushed once without touching the log.""" day = _plus(1) result = schedule_future_workout(stubs.log_file, _draft(), day) @@ -109,6 +113,7 @@ class TestScheduleFutureWorkout: def test_an_unpublished_entry_is_still_scheduled_and_says_so( self, stubs: SimpleNamespace ) -> None: + """A failed publish still schedules the entry and reports it is unpublished.""" stubs.push.return_value = PushResult( pushed=False, record_count=1, reason="Firebase down" ) @@ -117,6 +122,7 @@ class TestScheduleFutureWorkout: assert "not yet published: Firebase down" in result.reason def test_today_is_not_a_future_date(self, stubs: SimpleNamespace) -> None: + """Today is refused as a schedule date and nothing is pushed.""" result = schedule_future_workout(stubs.log_file, _draft(), _plus(0)) assert result.scheduled is False assert "is today" in result.reason @@ -126,11 +132,13 @@ class TestScheduleFutureWorkout: def test_a_date_outside_the_window_is_refused( self, stubs: SimpleNamespace, ahead: int ) -> None: + """Dates in the past or beyond the window are refused with nothing stored.""" result = schedule_future_workout(stubs.log_file, _draft(), _plus(ahead)) assert result.scheduled is False assert not stubs.ledger.exists() def test_an_exhausted_budget_is_refused(self, stubs: SimpleNamespace) -> None: + """Once two workouts are scheduled a third is refused for budget.""" assert schedule_future_workout( stubs.log_file, _draft("10:00"), _plus(1) ).scheduled @@ -143,6 +151,7 @@ class TestScheduleFutureWorkout: assert len(json.loads(stubs.ledger.read_text())) == 2 def test_the_same_entry_twice_is_refused(self, stubs: SimpleNamespace) -> None: + """Scheduling the same entry twice is refused and pushed only once.""" day = _plus(1) assert schedule_future_workout(stubs.log_file, _draft(), day).scheduled again = schedule_future_workout(stubs.log_file, _draft(), day) diff --git a/screen_locker/tests/test_manual_sync_future.py b/screen_locker/tests/test_manual_sync_future.py index f2007a9..ecbbf24 100644 --- a/screen_locker/tests/test_manual_sync_future.py +++ b/screen_locker/tests/test_manual_sync_future.py @@ -35,6 +35,7 @@ class TestDeferral: def test_a_future_record_is_not_ingested_or_credited( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A record dated after today is neither ingested nor credited and is logged as deferred.""" log_file = tmp_path / "log.json" calls: list[dict] = [] with caplog.at_level(logging.INFO): @@ -52,6 +53,7 @@ class TestDeferral: def test_the_same_record_is_ingested_and_credited_on_its_day( self, tmp_path: Path ) -> None: + """A deferred record is ingested and credited once its own day arrives.""" log_file = tmp_path / "log.json" record = _manual_record(_TT_DRAFT, _TOMORROW) calls: list[dict] = [] @@ -71,6 +73,7 @@ class TestDeferral: def test_a_garbled_date_is_skipped_loudly( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A record with an unparsable date is skipped with a warning.""" record_id, payload = _manual_record(_TT_DRAFT, _TODAY) payload["date"] = "2026-13-99" with caplog.at_level(logging.WARNING): @@ -82,10 +85,12 @@ class TestDeferral: assert "unparsable date" in caplog.text def test_an_empty_batch_touches_nothing(self, tmp_path: Path) -> None: + """An empty batch ingests nothing and does not create the log file.""" assert not ingest_manual_records(tmp_path / "log.json", [], today=_TODAY) assert not (tmp_path / "log.json").exists() def test_a_non_manual_record_is_ignored(self, tmp_path: Path) -> None: + """A record that is not a manual workout is ignored.""" assert not ( ingest_manual_records( tmp_path / "log.json", [("s", {"exercises": []})], today=_TODAY @@ -93,6 +98,7 @@ class TestDeferral: ) def test_defaults_to_the_real_local_day(self, tmp_path: Path) -> None: + """With no today argument the real local day is used, deferring a far-future record.""" record = _manual_record(_TT_DRAFT, "2999-01-01") assert not ingest_manual_records(tmp_path / "log.json", [record]) @@ -103,6 +109,7 @@ class TestBudgetAgainstTheWorkoutDate: def test_a_pending_record_does_not_reject_itself_on_its_day( self, tmp_path: Path ) -> None: + """A due record is not counted against itself while later records stay pending.""" log_file = tmp_path / "log.json" mine = _manual_record(_TT_DRAFT, _TOMORROW) later = _manual_record(_OTHER_DRAFT, "2026-10-18") @@ -117,6 +124,7 @@ class TestBudgetAgainstTheWorkoutDate: ) -> None: # Three records inside one 7-day window: whichever comes due while the # other two are still pending would be the third, so it is refused. + """Pending future records hold budget slots, so a due record that is third is refused.""" log_file = tmp_path / "log.json" due = _manual_record(_TT_DRAFT, _TOMORROW) pending = [ @@ -138,6 +146,7 @@ class TestBudgetAgainstTheWorkoutDate: assert "budget exhausted" in caplog.text def test_a_logged_week_blocks_a_due_record_inside_it(self, tmp_path: Path) -> None: + """Workouts already logged in the week block a due record that falls inside it.""" log_file = tmp_path / "log.json" for day, draft in (("2026-10-12", _TT_DRAFT), ("2026-10-13", _OTHER_DRAFT)): assert ingest_manual_records( diff --git a/screen_locker/tests/test_manual_workout_date.py b/screen_locker/tests/test_manual_workout_date.py index 1ee43f7..1a0b1ab 100644 --- a/screen_locker/tests/test_manual_workout_date.py +++ b/screen_locker/tests/test_manual_workout_date.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from screen_locker._day import today_str from screen_locker._manual_schedule import ScheduleResult -from screen_locker.tests import test_manual_workout_dialog as dialog_tests +from screen_locker.tests import test_manual_workout_dialog_part2 as dialog_tests from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -38,6 +38,7 @@ class TestBudgetClosed: def test_open_with_an_empty_log( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """With an empty log the budget is not closed.""" locker = create_locker(mock_tk, tmp_path) assert locker._mw_budget_closed() is False @@ -48,6 +49,7 @@ class TestDateInRow: def test_prefills_today_and_registers_the_var( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """The date entry is prefilled with today and registered in the form vars.""" locker = create_locker(mock_tk, tmp_path) locker._mw_vars = {} locker._mw_date_in_row(MagicMock()) @@ -61,6 +63,7 @@ class TestHandledByDate: def test_a_garbled_date_shows_the_error( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """An unparsable date shows an error and schedules nothing.""" locker = _locker(mock_tk, tmp_path, "soon") assert locker._mw_handled_by_date(MagicMock()) is True locker._mw_error_label.config.assert_called_once() @@ -69,6 +72,7 @@ class TestHandledByDate: def test_a_past_date_shows_the_error( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A date before today is rejected with a message mentioning the past.""" locker = _locker(mock_tk, tmp_path, _plus(-1)) assert locker._mw_handled_by_date(MagicMock()) is True assert "past" in locker._mw_error_label.config.call_args.kwargs["text"] @@ -76,6 +80,7 @@ class TestHandledByDate: def test_today_over_budget_shows_the_error( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Today's date with no budget left shows the budget error and is handled.""" locker = _locker(mock_tk, tmp_path, today_str()) with patch(f"{_MOD}.budget_error", return_value="budget full"): assert locker._mw_handled_by_date(MagicMock()) is True @@ -84,6 +89,7 @@ class TestHandledByDate: def test_today_within_budget_is_left_to_the_host( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Today's date within budget is not handled and shows no error.""" locker = _locker(mock_tk, tmp_path, today_str()) with patch(f"{_MOD}.budget_error", return_value=None): assert locker._mw_handled_by_date(MagicMock()) is False @@ -92,6 +98,7 @@ class TestHandledByDate: def test_a_refused_future_entry_shows_why( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A future entry the scheduler refuses shows its reason and no confirmation.""" locker = _locker(mock_tk, tmp_path, _plus(2)) refused = ScheduleResult(scheduled=False, record_id="r", reason="no room") with patch(f"{_MOD}.schedule_future_workout", return_value=refused): @@ -102,6 +109,7 @@ class TestHandledByDate: def test_a_scheduled_future_entry_shows_the_confirmation( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A scheduled future entry is saved via the scheduler and confirmed without error.""" locker = _locker(mock_tk, tmp_path, _plus(2)) draft = MagicMock() done = ScheduleResult(scheduled=True, record_id="r", reason="scheduled") @@ -118,6 +126,7 @@ class TestScheduledConfirmation: def test_ok_returns_via_the_cancelled_hook_and_never_unlocks( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """The OK button goes back through the cancelled hook and never the saved one.""" locker = create_locker(mock_tk, tmp_path) saved = MagicMock() cancelled = MagicMock() @@ -145,6 +154,7 @@ class TestSubmitRoutesThroughTheDateRules: def test_a_handled_entry_never_reaches_the_host_save_hook( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """When the date rules handle the entry the host save hook is not called.""" locker = dialog_tests.TestSubmitManualWorkoutForm()._setup_locker( mock_tk, tmp_path ) diff --git a/screen_locker/tests/test_manual_workout_dialog.py b/screen_locker/tests/test_manual_workout_dialog.py index 10f7cf0..f50843f 100644 --- a/screen_locker/tests/test_manual_workout_dialog.py +++ b/screen_locker/tests/test_manual_workout_dialog.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch from screen_locker import _manual_workout -from screen_locker._day import today_str from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -21,6 +20,7 @@ class TestShowManualWorkoutForm: def test_renders_form_when_budget_available( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """With budget left the form offers both SUBMIT and BACK.""" locker = create_locker(mock_tk, tmp_path) with patch(_CLOSED, return_value=False): locker._show_manual_workout_form() @@ -31,6 +31,7 @@ class TestShowManualWorkoutForm: def test_shows_exhausted_message_and_hides_submit( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """With no budget left an exhausted notice and BACK show, but no SUBMIT.""" locker = create_locker(mock_tk, tmp_path) with patch(_CLOSED, return_value=True): locker._show_manual_workout_form() @@ -47,6 +48,7 @@ class TestSportToggle: def test_selecting_other_shows_other_frame( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Choosing Other hides the table-tennis fields and shows the free-form ones.""" locker = create_locker(mock_tk, tmp_path) locker._mw_tt_frame = MagicMock() locker._mw_other_frame = MagicMock() @@ -58,6 +60,7 @@ class TestSportToggle: def test_selecting_table_tennis_shows_tt_frame( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Choosing Table tennis hides the free-form fields and shows the table-tennis ones.""" locker = create_locker(mock_tk, tmp_path) locker._mw_tt_frame = MagicMock() locker._mw_other_frame = MagicMock() @@ -69,6 +72,7 @@ class TestSportToggle: def test_unknown_label_defaults_to_table_tennis( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """An unrecognised sport label falls back to table tennis.""" locker = create_locker(mock_tk, tmp_path) locker._mw_tt_frame = MagicMock() locker._mw_other_frame = MagicMock() @@ -83,6 +87,7 @@ class TestGridCursor: def test_full_row_from_even_cursor_advances_by_a_row( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A full-width item from a fresh cursor takes row 0 and the next takes row 1.""" locker = create_locker(mock_tk, tmp_path) locker._mw_grid_counters = {} parent = object() @@ -93,6 +98,7 @@ class TestGridCursor: def test_full_row_from_odd_cursor_bumps_to_next_row( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A full-width item after a lone half-width cell moves to a fresh row.""" locker = create_locker(mock_tk, tmp_path) parent = object() # An odd cursor (a half-width cell already placed on the left) must be @@ -101,144 +107,13 @@ class TestGridCursor: assert locker._mw_next_full_row(parent) == 1 -class TestSubmitManualWorkoutForm: - """Tests for _submit_manual_workout_form.""" - - def _setup_locker( - self, - mock_tk: MagicMock, - tmp_path: Path, - *, - sport_label: str = "Table tennis", - fields: dict[str, object] | None = None, - ) -> object: - defaults: dict[str, object] = { - "date": today_str(), - "start_time": "12:00", - "end_time": "14:00", - "location_name": "Osrodek Solec", - "transport_method": "bike", - "cost": "60 PLN", - "reservation_phone": "", - "techniques_practiced": "", - "warm_up_minutes": "", - "pain_or_injury": "none", - "racket": "pro spin", - "balls": "nittaku", - "activity_type_other": "squash", - "went_well": "x" * 30, - "to_improve": "y" * 30, - "overall_feeling": "z" * 30, - "activity_details": "q" * 45, - } - if fields: - defaults.update(fields) - - locker = create_locker(mock_tk, tmp_path) - locker._mw_sport_var = MagicMock() - locker._mw_sport_var.get.return_value = sport_label - locker._mw_rpe_var = MagicMock() - locker._mw_rpe_var.get.return_value = 6 - - locker._mw_vars = {} - for key in ( - "start_time", - "end_time", - "location_name", - "transport_method", - "cost", - "reservation_phone", - "techniques_practiced", - "warm_up_minutes", - "pain_or_injury", - "racket", - "balls", - "activity_type_other", - "equipment", - "date", - ): - var = MagicMock() - var.get.return_value = defaults.get(key, "") - locker._mw_vars[key] = var - - locker._mw_int_vars = {} - for key, default_val in ( - ("matches_won", 1), - ("matches_lost", 4), - ("sets_won", 2), - ("sets_lost", 9), - ): - var = MagicMock() - var.get.return_value = defaults.get(key, default_val) - locker._mw_int_vars[key] = var - - locker._mw_text_widgets = {} - for key in ("went_well", "to_improve", "overall_feeling", "activity_details"): - widget = MagicMock() - widget.get.return_value = defaults[key] - locker._mw_text_widgets[key] = widget - - locker._mw_error_label = MagicMock() - object.__setattr__(locker, "_on_manual_workout_saved", MagicMock()) - return locker - - def test_valid_table_tennis_calls_saved_hook( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path, sport_label="Table tennis") - locker._submit_manual_workout_form() - locker._on_manual_workout_saved.assert_called_once() - entry = locker._on_manual_workout_saved.call_args.args[0] - assert entry["sport"] == "table_tennis" - assert entry["matches_lost"] == 4 - locker._mw_error_label.config.assert_not_called() - - def test_valid_other_sport_calls_saved_hook( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path, sport_label="Other") - locker._submit_manual_workout_form() - locker._on_manual_workout_saved.assert_called_once() - entry = locker._on_manual_workout_saved.call_args.args[0] - assert entry["sport"] == "other" - assert entry["activity_type"] == "squash" - - def test_validation_failure_shows_error_and_skips_hook( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path, fields={"location_name": " "}) - locker._submit_manual_workout_form() - locker._mw_error_label.config.assert_called_once() - locker._on_manual_workout_saved.assert_not_called() - - def test_rpe_value_error_treated_as_invalid( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - locker._mw_rpe_var.get.side_effect = ValueError("bad") - locker._submit_manual_workout_form() - locker._mw_error_label.config.assert_called_once() - locker._on_manual_workout_saved.assert_not_called() - - def test_int_field_value_error_treated_as_zero( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - locker._mw_int_vars["matches_won"].get.side_effect = ValueError("bad") - locker._mw_int_vars["matches_lost"].get.return_value = 0 - locker._submit_manual_workout_form() - # matches_won=0 (from the ValueError fallback) and matches_lost=0 - # -> "zero matches played" validation error. - locker._mw_error_label.config.assert_called_once() - locker._on_manual_workout_saved.assert_not_called() - - class TestInitialKeyboardFocus: """Exactly one field takes focus when a typed-input screen is built.""" def test_manual_workout_form_focuses_one_entry( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Building the form gives keyboard focus to exactly one entry.""" locker = create_locker(mock_tk, tmp_path) with patch(_CLOSED, return_value=False): locker._show_manual_workout_form() diff --git a/screen_locker/tests/test_manual_workout_dialog_part2.py b/screen_locker/tests/test_manual_workout_dialog_part2.py new file mode 100644 index 0000000..da16670 --- /dev/null +++ b/screen_locker/tests/test_manual_workout_dialog_part2.py @@ -0,0 +1,149 @@ +"""Tests for submitting the manual-workout evidence form.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from screen_locker._day import today_str +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSubmitManualWorkoutForm: + """Tests for _submit_manual_workout_form.""" + + def _setup_locker( + self, + mock_tk: MagicMock, + tmp_path: Path, + *, + sport_label: str = "Table tennis", + fields: dict[str, object] | None = None, + ) -> object: + defaults: dict[str, object] = { + "date": today_str(), + "start_time": "12:00", + "end_time": "14:00", + "location_name": "Osrodek Solec", + "transport_method": "bike", + "cost": "60 PLN", + "reservation_phone": "", + "techniques_practiced": "", + "warm_up_minutes": "", + "pain_or_injury": "none", + "racket": "pro spin", + "balls": "nittaku", + "activity_type_other": "squash", + "went_well": "x" * 30, + "to_improve": "y" * 30, + "overall_feeling": "z" * 30, + "activity_details": "q" * 45, + } + if fields: + defaults.update(fields) + + locker = create_locker(mock_tk, tmp_path) + locker._mw_sport_var = MagicMock() + locker._mw_sport_var.get.return_value = sport_label + locker._mw_rpe_var = MagicMock() + locker._mw_rpe_var.get.return_value = 6 + + locker._mw_vars = {} + for key in ( + "start_time", + "end_time", + "location_name", + "transport_method", + "cost", + "reservation_phone", + "techniques_practiced", + "warm_up_minutes", + "pain_or_injury", + "racket", + "balls", + "activity_type_other", + "equipment", + "date", + ): + var = MagicMock() + var.get.return_value = defaults.get(key, "") + locker._mw_vars[key] = var + + locker._mw_int_vars = {} + for key, default_val in ( + ("matches_won", 1), + ("matches_lost", 4), + ("sets_won", 2), + ("sets_lost", 9), + ): + var = MagicMock() + var.get.return_value = defaults.get(key, default_val) + locker._mw_int_vars[key] = var + + locker._mw_text_widgets = {} + for key in ("went_well", "to_improve", "overall_feeling", "activity_details"): + widget = MagicMock() + widget.get.return_value = defaults[key] + locker._mw_text_widgets[key] = widget + + locker._mw_error_label = MagicMock() + object.__setattr__(locker, "_on_manual_workout_saved", MagicMock()) + return locker + + def test_valid_table_tennis_calls_saved_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """A valid table-tennis form is passed to the saved hook with no error shown.""" + locker = self._setup_locker(mock_tk, tmp_path, sport_label="Table tennis") + locker._submit_manual_workout_form() + locker._on_manual_workout_saved.assert_called_once() + entry = locker._on_manual_workout_saved.call_args.args[0] + assert entry["sport"] == "table_tennis" + assert entry["matches_lost"] == 4 + locker._mw_error_label.config.assert_not_called() + + def test_valid_other_sport_calls_saved_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """A valid Other-sport form is saved with its free-form activity type.""" + locker = self._setup_locker(mock_tk, tmp_path, sport_label="Other") + locker._submit_manual_workout_form() + locker._on_manual_workout_saved.assert_called_once() + entry = locker._on_manual_workout_saved.call_args.args[0] + assert entry["sport"] == "other" + assert entry["activity_type"] == "squash" + + def test_validation_failure_shows_error_and_skips_hook( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """A validation failure shows the error and does not save.""" + locker = self._setup_locker(mock_tk, tmp_path, fields={"location_name": " "}) + locker._submit_manual_workout_form() + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() + + def test_rpe_value_error_treated_as_invalid( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """An unreadable RPE value is reported as an error and nothing is saved.""" + locker = self._setup_locker(mock_tk, tmp_path) + locker._mw_rpe_var.get.side_effect = ValueError("bad") + locker._submit_manual_workout_form() + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() + + def test_int_field_value_error_treated_as_zero( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """An unreadable match count counts as zero, tripping the zero-matches error.""" + locker = self._setup_locker(mock_tk, tmp_path) + locker._mw_int_vars["matches_won"].get.side_effect = ValueError("bad") + locker._mw_int_vars["matches_lost"].get.return_value = 0 + locker._submit_manual_workout_form() + # matches_won=0 (from the ValueError fallback) and matches_lost=0 + # -> "zero matches played" validation error. + locker._mw_error_label.config.assert_called_once() + locker._on_manual_workout_saved.assert_not_called() diff --git a/screen_locker/tests/test_manual_workout_status_view.py b/screen_locker/tests/test_manual_workout_status_view.py index 6bc084e..8eb4951 100644 --- a/screen_locker/tests/test_manual_workout_status_view.py +++ b/screen_locker/tests/test_manual_workout_status_view.py @@ -23,6 +23,7 @@ class TestSectionManualWorkoutBudget: """Tests for StatusWindow._section_manual_workout_budget.""" def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None: + """An exhausted weekly budget is drawn in the danger colour.""" snap = _snapshot( manual_workout_budget=_manual_workout_budget(used_7d=2, exhausted=True) ) @@ -35,6 +36,7 @@ class TestSectionManualWorkoutBudget: assert any(c.kwargs.get("fg") == window._colors.palette.danger for c in calls) def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None: + """A budget that is not exhausted is drawn in the muted colour.""" snap = _snapshot( manual_workout_budget=_manual_workout_budget(used_7d=0, exhausted=False) ) @@ -53,12 +55,14 @@ class TestManualWorkoutButtonVisibility: def test_shown_when_budget_available( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """The Log Manual Workout button appears while budget remains.""" _make_window(mock_tk, _snapshot(), log_file=temp_log_file) assert "Log Manual Workout" in _button_texts(mock_tk) def test_hidden_when_budget_exhausted( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """The Log Manual Workout button is hidden once the budget is closed.""" with patch(_CLOSED, return_value=True): _make_window(mock_tk, _snapshot(), log_file=temp_log_file) assert "Log Manual Workout" not in _button_texts(mock_tk) @@ -68,6 +72,7 @@ class TestOnManualWorkoutSaved: """Tests for StatusWindow._on_manual_workout_saved.""" def _make_fake_verifier(self, credit: WorkoutCreditResult) -> MagicMock: + """A stand-in verifier whose credit application returns ``credit``.""" fake_verifier = MagicMock() fake_verifier._apply_workout_credit = MagicMock(return_value=credit) return fake_verifier @@ -75,6 +80,7 @@ class TestOnManualWorkoutSaved: def test_saves_via_bare_verifier_and_shows_shutdown_message( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """Saving applies the credit via the verifier and shows the shutdown extension.""" credit = WorkoutCreditResult( shutdown_adjusted=True, extra_bonus_delta=0, @@ -102,6 +108,7 @@ class TestOnManualWorkoutSaved: def test_shows_extra_bonus_line_and_no_debt( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """An extra workout shows the extra-bonus line and never mentions debt.""" credit = WorkoutCreditResult( shutdown_adjusted=False, extra_bonus_delta=60, @@ -126,6 +133,7 @@ class TestOnManualWorkoutSaved: def test_already_counted_today_shows_no_extra_credit_note( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """A second workout the same day says no additional credit was given.""" credit = WorkoutCreditResult( shutdown_adjusted=False, extra_bonus_delta=0, @@ -154,6 +162,7 @@ class TestOnManualWorkoutCancelled: def test_rerenders_current_snapshot( self, mock_tk: MagicMock, temp_log_file: Path ) -> None: + """Cancelling the dialog re-gathers and re-renders the status.""" window = _make_window(mock_tk, _snapshot(), log_file=temp_log_file) with patch( "screen_locker.status_view.gather_status", return_value=_snapshot() @@ -166,6 +175,7 @@ class TestManualWorkoutSavedMessageDisplay: """Tests for the saved-message line in StatusWindow.render.""" def test_shown_when_present(self, mock_tk: MagicMock, temp_log_file: Path) -> None: + """A pending saved message is rendered as a label.""" window = _make_window(mock_tk, _snapshot(), log_file=temp_log_file) window._credit_message = "Manual workout logged: test" window.render(_snapshot()) @@ -173,6 +183,7 @@ class TestManualWorkoutSavedMessageDisplay: assert any("Manual workout logged: test" in t for t in texts) def test_absent_when_none(self, mock_tk: MagicMock, temp_log_file: Path) -> None: + """No saved-message label is rendered when there is none.""" _make_window(mock_tk, _snapshot(), log_file=temp_log_file) texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list] assert not any("Manual workout logged" in t for t in texts) diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index 4611407..ea17179 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -78,7 +78,10 @@ def _snapshot() -> StatusSnapshot: class TestReadTools: + """The read-only MCP tools expose the status snapshot as plain JSON.""" + def test_get_status_returns_json_friendly_asdict(self) -> None: + """get_status returns the snapshot as a JSON-serialisable dict.""" snap = _snapshot() with patch.object(_mcp, "gather_status", return_value=snap) as gs: out = _mcp.get_status() @@ -95,6 +98,7 @@ class TestReadTools: assert out["early_bird_extended"] is False def test_get_summary_combines_line_and_state(self) -> None: + """get_summary combines the summary line with the compliance state word.""" with ( patch.object(_mcp, "gather_status") as gs, patch.object(_mcp, "format_summary_line", return_value="LINE") as fsl, @@ -106,6 +110,7 @@ class TestReadTools: csw.assert_called_once_with(gs.return_value) def test_explain_lock_returns_lock_explanation_asdict(self) -> None: + """explain_lock returns the snapshot's lock explanation as a dict.""" snap = _snapshot() with patch.object(_mcp, "gather_status", return_value=snap): out = _mcp.explain_lock() @@ -115,7 +120,10 @@ class TestReadTools: class TestGetFlags: + """get_flags reports the four daily predicates.""" + def test_flags_dict_and_predicate_wiring(self) -> None: + """get_flags maps each predicate to its key and passes it the right file argument.""" with ( patch.object(_mcp, "has_logged_today", return_value=True) as hlt, patch.object(_mcp, "is_scheduled_skip_today", return_value=False) as iss, @@ -136,6 +144,7 @@ class TestGetFlags: def test_main_runs_stdio_server() -> None: + """main starts the MCP server over stdio.""" with patch.object(_mcp.mcp, "run") as run: _mcp.main() run.assert_called_once_with() diff --git a/screen_locker/tests/test_morning_session.py b/screen_locker/tests/test_morning_session.py index 11f8ebc..20ab095 100644 --- a/screen_locker/tests/test_morning_session.py +++ b/screen_locker/tests/test_morning_session.py @@ -50,6 +50,7 @@ def _write(date: str, exempt_until: str) -> None: def test_reads_the_redirected_file() -> None: + """The module reads the conftest-redirected file and reports the signed skip.""" _write("2026-09-19", "11:00") skip = morning_skip_today(NOW, wait=False) assert skip == MorningSkip("completed", NOW.replace(hour=11)) @@ -57,10 +58,12 @@ def test_reads_the_redirected_file() -> None: def test_missing_file_is_no_skip() -> None: + """A missing session file means no skip.""" assert morning_skip_today(NOW, wait=False) is None def test_has_workout_skip_today_uses_the_wall_clock() -> None: + """has_workout_skip_today judges the entry against the real wall clock.""" _write(datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d"), "23:59") assert has_workout_skip_today() is True diff --git a/screen_locker/tests/test_night_lockdown_rung.py b/screen_locker/tests/test_night_lockdown_rung.py index e35aa19..007f869 100644 --- a/screen_locker/tests/test_night_lockdown_rung.py +++ b/screen_locker/tests/test_night_lockdown_rung.py @@ -38,21 +38,26 @@ class TestTonightsLockdown: """The binding reads the (redirected) stamp through gatelock.""" def test_missing_stamp_is_no_lockdown(self) -> None: + """Without a stamp file there is no lockdown tonight.""" assert tonights_lockdown(NOW) is None def test_evening_stamp_today_is_tonights_lockdown(self) -> None: + """An evening stamp from today is returned as tonight's lockdown time.""" _stamp("2026-10-17T21:05") assert tonights_lockdown(NOW) == EVENING def test_morning_relock_today_is_not(self) -> None: + """An early-morning stamp from today is not the evening lockdown.""" _stamp("2026-10-17T04:01") assert tonights_lockdown(NOW) is None def test_yesterdays_evening_is_not(self) -> None: + """Yesterday's evening stamp does not count for tonight.""" _stamp("2026-10-16T21:05") assert tonights_lockdown(NOW) is None def test_garbage_is_not(self) -> None: + """A stamp file that is not an epoch is treated as no lockdown.""" _night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text( "not an epoch", encoding="utf-8" ) @@ -63,12 +68,14 @@ class TestSkipAfterEveningLockdown: """The rung on its own.""" def test_no_lockdown_lets_the_lock_proceed(self) -> None: + """With no lockdown tonight the rung does not skip and records nothing.""" record_skip = MagicMock() with patch.object(_night_lockdown, "tonights_lockdown", return_value=None): assert skip_after_evening_lockdown(record_skip) is False record_skip.assert_not_called() def test_tonights_lockdown_records_the_skip(self) -> None: + """Tonight's lockdown records an evening_lockdown_today skip carrying its time.""" record_skip = MagicMock() with patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING): assert skip_after_evening_lockdown(record_skip) is True @@ -105,6 +112,7 @@ class TestLadder: def test_after_tonights_lockdown_the_lock_does_not_rearm( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """After the evening lockdown the ladder records the skip and exits without locking.""" locker = create_locker(mock_tk, tmp_path) with ( patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING), @@ -126,6 +134,7 @@ class TestLadder: def test_morning_relock_still_enforces( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A morning relock stamp does not stop the ladder from enforcing the lock.""" _stamp(f"{datetime.now(tz=UTC).astimezone():%Y-%m-%d}T04:01") locker = create_locker(mock_tk, tmp_path) with ( @@ -141,6 +150,7 @@ class TestLadder: def test_other_conditions_name_tonights_lockdown( self, mock_tk: MagicMock, tmp_path: Path ) -> None: + """The other-conditions report lists the evening lockdown when it happened.""" locker = create_locker(mock_tk, tmp_path) with patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING): also = locker._other_conditions("enforced") @@ -160,10 +170,12 @@ class TestStatusMirror: ) def test_tonights_lockdown_is_the_terminal_stage(self, tmp_path: Path) -> None: + """The status explanation stops at evening_lockdown_today without firing.""" result = self._explain(tmp_path, evening_lockdown=True) assert result.stage == "evening_lockdown_today" assert result.fired is False def test_without_it_the_chain_still_would_lock(self, tmp_path: Path) -> None: + """Without a lockdown the status explanation still reaches would_lock.""" result = self._explain(tmp_path, evening_lockdown=False) assert result.stage == "would_lock" diff --git a/screen_locker/tests/test_phone_verification_part5.py b/screen_locker/tests/test_phone_verification_part5.py index 6fca08f..884964d 100644 --- a/screen_locker/tests/test_phone_verification_part5.py +++ b/screen_locker/tests/test_phone_verification_part5.py @@ -35,6 +35,7 @@ class TestTryFillStronglifitsForWeek: def test_returns_zero_when_no_phone( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Without an adb-connected phone the week scan fills nothing.""" locker = create_locker(mock_tk, tmp_path) log_file = tmp_path / "log.json" log_file.write_text("{}") @@ -44,6 +45,7 @@ class TestTryFillStronglifitsForWeek: def test_returns_zero_when_no_json_pulled( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """If no workout JSON can be pulled from the phone, nothing is filled.""" locker = create_locker(mock_tk, tmp_path) log_file = tmp_path / "log.json" log_file.write_text("{}") @@ -56,6 +58,7 @@ class TestTryFillStronglifitsForWeek: def test_returns_zero_when_date_unparsable( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A pulled JSON whose date does not parse is not backfilled.""" locker = create_locker(mock_tk, tmp_path) log_file = tmp_path / "log.json" log_file.write_text("{}") @@ -143,6 +146,7 @@ class TestTryFillStronglifitsForWeek: def test_rescanning_an_already_filled_day_is_a_no_op( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Scanning the same day twice fills it once, then reports nothing new.""" locker = create_locker(mock_tk, tmp_path) log_file = tmp_path / "log.json" log_file.write_text("{}") diff --git a/screen_locker/tests/test_poke_contract.py b/screen_locker/tests/test_poke_contract.py index a6add0f..9340153 100644 --- a/screen_locker/tests/test_poke_contract.py +++ b/screen_locker/tests/test_poke_contract.py @@ -31,11 +31,13 @@ def _fixture() -> tuple[bytes, bytes, str]: def test_fixture_signature_verifies_over_raw_bytes() -> None: + """The contract's signature verifies against the key and the exact raw body bytes.""" key, body, signature = _fixture() assert verify_signature(key, body, signature) def test_one_altered_byte_is_rejected() -> None: + """Changing a single body byte, while still valid JSON, fails the signature check.""" key, body, signature = _fixture() altered = bytearray(body) # The '1' in "v":1 -> '2': still valid JSON, so only the MAC can catch it. @@ -44,6 +46,7 @@ def test_one_altered_byte_is_rejected() -> None: def test_fixture_passes_every_contract_check() -> None: + """The contract request passes every check and decodes to the expected record and payload.""" key, body, signature = _fixture() sent_at = json.loads(body)["sent_at_ms"] result = check_request(key, body, signature, NonceCache(), now_ms=sent_at) @@ -54,6 +57,7 @@ def test_fixture_passes_every_contract_check() -> None: def test_altered_body_is_a_401_through_check_request() -> None: + """A tampered body is rejected by check_request with HTTP 401.""" key, body, signature = _fixture() altered = body.replace(b'"sandbox":false', b'"sandbox":true ') result = check_request(key, altered, signature, NonceCache(), now_ms=0) diff --git a/screen_locker/tests/test_poke_credit.py b/screen_locker/tests/test_poke_credit.py index bb4fd84..07eb1e4 100644 --- a/screen_locker/tests/test_poke_credit.py +++ b/screen_locker/tests/test_poke_credit.py @@ -26,6 +26,7 @@ _DAY = "2026-10-09" def _session(**overrides: object) -> dict[str, Any]: + """Build a synced session payload for the poke tests, with optional overrides.""" payload: dict[str, Any] = { "workout_type": "B", "date": _DAY, @@ -39,6 +40,7 @@ def _session(**overrides: object) -> dict[str, Any]: def _result(*, counted: bool = False) -> WorkoutCreditResult: + """Build a WorkoutCreditResult, optionally marking the slot as already counted.""" return WorkoutCreditResult( shutdown_adjusted=not counted, extra_bonus_delta=0, @@ -53,6 +55,7 @@ def log_file_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: log = tmp_path / "poke_log.json" def headless(locker_cls: type[PokeLocker]) -> PokeLocker: + """Build a PokeLocker without a display, pointed at the temporary log.""" locker = object.__new__(locker_cls) locker.log_file = log locker.workout_data = {} @@ -73,14 +76,18 @@ def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock: def _logged_ids(log: Path) -> list[str]: + """Return the sync record ids of every entry in the poke log.""" data = json.loads(log.read_text()) return [e["workout_data"]["sync_record_id"] for es in data.values() for e in es] class TestCreditSession: + """Groups crediting a single poked session end to end.""" + def test_happy_path_logs_and_credits_once( self, log_file: Path, credit: MagicMock ) -> None: + """A valid session is logged under its start time and credited exactly once.""" outcome = credit_session("r1", _session()) assert outcome == PokeOutcome( ok=True, credited=True, duplicate=False, reason="workout credited" @@ -91,6 +98,7 @@ class TestCreditSession: def test_second_poke_of_the_same_session_is_a_duplicate( self, log_file: Path, credit: MagicMock ) -> None: + """A second poke of one session is a duplicate: not logged or credited again.""" credit_session("r1", _session()) outcome = credit_session("r1", _session()) assert (outcome.ok, outcome.credited, outcome.duplicate) == (True, False, True) @@ -104,6 +112,7 @@ class TestCreditSession: credit: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """When the slot is already paid the session is logged and warned about but not credited.""" credit.return_value = _result(counted=True) with caplog.at_level(logging.WARNING): outcome = credit_session("r1", _session()) @@ -116,6 +125,7 @@ class TestCreditSession: def test_session_without_a_usable_date_is_refused( self, log_file: Path, credit: MagicMock, day: object ) -> None: + """A session with a missing, empty or non-string date is refused and nothing is written.""" outcome = credit_session("r1", _session(date=day)) assert (outcome.ok, outcome.credited, outcome.duplicate) == ( False, @@ -141,6 +151,7 @@ class TestCreditSession: overrides: dict[str, object], fragment: str, ) -> None: + """An invalid session is refused with its validation reason, writing nothing.""" outcome = credit_session("r1", _session(**overrides)) assert outcome.ok is False assert "does not count" in outcome.reason @@ -151,12 +162,15 @@ class TestCreditSession: def test_the_day_is_reset_before_the_credit( self, log_file: Path, credit: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: + """The day reset runs before the credit step.""" order: list[str] = [] def reset(_self: PokeLocker) -> None: + """Record that the day reset ran.""" order.append("reset") def paid(*_args: object) -> WorkoutCreditResult: + """Record that the credit ran and return a fresh result.""" order.append("credit") return _result() @@ -167,9 +181,12 @@ class TestCreditSession: class TestCreditSessions: + """Groups crediting a batch of streamed sessions.""" + def test_batch_counts_ingested_and_credited( self, log_file: Path, credit: MagicMock ) -> None: + """A batch reports every newly ingested id and how many were credited.""" other = _session(date="2026-10-08", start_time="2026-10-08T09:00:00.000") outcome = credit_sessions([("r1", _session()), ("r2", other)]) assert outcome == BatchOutcome(ingested=("r1", "r2"), credited=2) @@ -178,6 +195,7 @@ class TestCreditSessions: def test_already_logged_ids_cost_nothing( self, log_file: Path, credit: MagicMock ) -> None: + """Re-sending already logged ids ingests and credits nothing.""" credit_sessions([("r1", _session())]) outcome = credit_sessions([("r1", _session())]) assert outcome == BatchOutcome(ingested=(), credited=0) @@ -186,6 +204,7 @@ class TestCreditSessions: def test_invalid_session_in_a_batch_is_skipped( self, log_file: Path, credit: MagicMock ) -> None: + """An invalid session in a batch is skipped without writing a log.""" outcome = credit_sessions([("bad", _session(exercises=[]))]) assert outcome == BatchOutcome(ingested=(), credited=0) assert not log_file.exists() @@ -196,6 +215,7 @@ class TestCreditSessions: credit: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """A batch session on an already paid slot is ingested uncredited and warned about.""" credit.return_value = _result(counted=True) with caplog.at_level(logging.WARNING): outcome = credit_sessions([("r1", _session())]) diff --git a/screen_locker/tests/test_poke_credit_day.py b/screen_locker/tests/test_poke_credit_day.py index 123c961..3355a1e 100644 --- a/screen_locker/tests/test_poke_credit_day.py +++ b/screen_locker/tests/test_poke_credit_day.py @@ -22,6 +22,7 @@ def log_file_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: log = tmp_path / "poke_log.json" def headless(locker_cls: type[PokeLocker]) -> PokeLocker: + """Build a PokeLocker without a display, pointed at the temporary log.""" locker = object.__new__(locker_cls) locker.log_file = log locker.workout_data = {} @@ -49,16 +50,21 @@ def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock: class TestStartTheDay: + """Groups the day-start steps run before a poked session is credited.""" + def test_new_day_runs_the_week_transition_then_the_reset( self, log_file: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Starting the day runs the week transition first, then the base reset.""" calls: list[str] = [] def week(*_args: object) -> list[str]: + """Record the week transition and return a streak message.""" calls.append("week") return ["+1h streak"] def reset(*_args: object, **_kwargs: object) -> bool: + """Record the base reset and report it ran.""" calls.append("reset") return True @@ -70,6 +76,7 @@ class TestStartTheDay: def test_credit_written_sets_the_entry_and_returns_the_result( self, log_file: Path, credit: MagicMock ) -> None: + """credit_written stores the entry as workout data and returns the shared credit result.""" locker = _poke_credit.fresh_locker() assert locker.credit_written({"type": "x"}, [{"a": 1}]) == credit.return_value assert locker.workout_data == {"type": "x"} @@ -77,9 +84,12 @@ class TestStartTheDay: class TestSnapshots: + """Groups the shutdown and gaming-minutes values snapshotted into poke replies.""" + def test_current_shutdown_renders_tonights_time( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """current_shutdown renders tonight's shutdown minutes as HH:MM.""" monkeypatch.setattr(_poke_credit, "read_shutdown_config", lambda _p: (1, 2, 3)) monkeypatch.setattr(_poke_credit, "tonight_minutes", lambda _c, _d: 20 * 60 + 5) assert _poke_credit.current_shutdown() == "20:05" @@ -87,6 +97,7 @@ class TestSnapshots: def test_unreadable_config_is_none_and_warned( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: + """An unreadable config gives a None shutdown and a shutdown=null warning.""" monkeypatch.setattr(_poke_credit, "read_shutdown_config", lambda _p: None) with caplog.at_level(logging.WARNING): assert _poke_credit.current_shutdown() is None @@ -95,9 +106,11 @@ class TestSnapshots: def test_current_gaming_minutes_delegates( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """current_gaming_minutes returns what gaming_minutes_today reports for the log.""" seen: list[Path] = [] def minutes(log: Path) -> int: + """Record the log path asked about and return 90 minutes.""" seen.append(log) return 90 diff --git a/screen_locker/tests/test_poke_main.py b/screen_locker/tests/test_poke_main.py index ce2610d..6027463 100644 --- a/screen_locker/tests/test_poke_main.py +++ b/screen_locker/tests/test_poke_main.py @@ -38,9 +38,11 @@ class FakeServer: self.instances.append(self) def serve_forever(self) -> None: + """Return immediately so main() proceeds to shutdown.""" return def server_close(self) -> None: + """Record that the daemon closed the server.""" self.closed = True @@ -65,29 +67,38 @@ def stream_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock: class TestLoadKey: + """Loading the shared poke key from disk and refusing unsafe files.""" + def test_reads_a_600_key(self, tmp_path: Path) -> None: + """A 0600 file holding 64 hex digits loads as 32 raw bytes.""" assert poke_server.load_key(_key_file(tmp_path)) == bytes.fromhex(_KEY_HEX) @pytest.mark.parametrize("mode", [0o640, 0o604, 0o660, 0o644]) def test_group_or_world_access_is_refused_with_the_fix( self, tmp_path: Path, mode: int ) -> None: + """Any group or world access to the key file is refused with a chmod 600 hint.""" with pytest.raises(ValueError, match="chmod 600"): poke_server.load_key(_key_file(tmp_path, mode=mode)) def test_malformed_key_is_refused(self, tmp_path: Path) -> None: + """A key that is not valid hex of the right length is refused.""" with pytest.raises(ValueError, match="hex"): poke_server.load_key(_key_file(tmp_path, text="short")) def test_missing_file_raises_oserror(self, tmp_path: Path) -> None: + """A missing key file surfaces as an OSError mentioning the key.""" with pytest.raises(OSError, match="key"): poke_server.load_key(tmp_path / "key") class TestMain: + """Daemon startup: refusals, serving, the Firebase stream and cleanup.""" + def test_missing_key_refuses_to_start_at_critical( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A missing key logs at CRITICAL, exits 1 and never builds a server.""" with caplog.at_level(logging.CRITICAL): code = poke_server.main(["--key-file", str(tmp_path / "nope")]) assert code == 1 @@ -96,6 +107,7 @@ class TestMain: assert not FakeServer.instances def test_loose_key_mode_refuses_to_start(self, tmp_path: Path) -> None: + """A world-readable key makes startup fail without building a server.""" key = _key_file(tmp_path, mode=0o644) assert poke_server.main(["--key-file", str(key)]) == 1 assert not FakeServer.instances @@ -106,6 +118,8 @@ class TestMain: monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: + """A bind error logs the port and reason at CRITICAL and exits 1.""" + def busy(_address: tuple[str, int], _key: bytes) -> Any: msg = "address in use" raise OSError(msg) @@ -124,6 +138,7 @@ class TestMain: stream: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """The server gets the loaded key and address, the stream starts, and it is closed.""" argv = ["--key-file", str(_key_file(tmp_path)), "--port", "1234"] with caplog.at_level(logging.INFO): code = poke_server.main([*argv, "--bind", "127.0.0.1"]) @@ -142,6 +157,7 @@ class TestMain: def test_default_bind_is_all_interfaces_on_the_default_port( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Without --bind and --port the server listens on all interfaces at the default port.""" with caplog.at_level(logging.INFO): poke_server.main(["--key-file", str(_key_file(tmp_path))]) assert FakeServer.instances[0].address == ("", poke_server.DEFAULT_PORT) @@ -153,6 +169,7 @@ class TestMain: stream: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """--no-stream skips the Firebase stream and logs that it is disabled.""" with caplog.at_level(logging.WARNING): code = poke_server.main( ["--key-file", str(_key_file(tmp_path)), "--no-stream"] @@ -168,6 +185,7 @@ class TestMain: stream: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """A stream start failure is logged but the LAN server still runs and closes.""" stream.return_value.start.side_effect = RuntimeError("no network") with caplog.at_level(logging.ERROR): code = poke_server.main(["--key-file", str(_key_file(tmp_path))]) @@ -178,6 +196,8 @@ class TestMain: def test_server_is_closed_even_when_serving_raises( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """The server socket is closed even when serving is interrupted.""" + def explode(_self: FakeServer) -> None: raise KeyboardInterrupt diff --git a/screen_locker/tests/test_poke_sandbox.py b/screen_locker/tests/test_poke_sandbox.py index 09f1705..4bde631 100644 --- a/screen_locker/tests/test_poke_sandbox.py +++ b/screen_locker/tests/test_poke_sandbox.py @@ -56,6 +56,7 @@ def pc_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Mag def test_sandbox_replies_ok_with_the_snapshot_and_nothing_credited( pc: dict[str, MagicMock], ) -> None: + """A sandbox poke is answered ok with the PC snapshot and reports nothing credited.""" reply = _poke_server._process(_request(sandbox=True)) assert reply["ok"] is True assert reply["sandbox"] is True @@ -69,6 +70,7 @@ def test_sandbox_replies_ok_with_the_snapshot_and_nothing_credited( def test_sandbox_never_logs_credits_or_touches_the_shutdown_config( pc: dict[str, MagicMock], tmp_path: Path ) -> None: + """A sandbox poke leaves every state-changing step uncalled and no files behind.""" before = sorted(p.name for p in tmp_path.iterdir()) _poke_server._process(_request(sandbox=True)) for name, spy in pc.items(): diff --git a/screen_locker/tests/test_poke_server.py b/screen_locker/tests/test_poke_server.py index 075b712..6aaea40 100644 --- a/screen_locker/tests/test_poke_server.py +++ b/screen_locker/tests/test_poke_server.py @@ -14,8 +14,8 @@ from unittest.mock import MagicMock import pytest -from screen_locker import _poke_credit, _poke_server -from screen_locker._poke_server import MAX_BODY_BYTES, POKE_PATH, PokeServer +from screen_locker import _poke_credit +from screen_locker._poke_server import POKE_PATH, PokeServer if TYPE_CHECKING: from collections.abc import Iterator @@ -116,9 +116,12 @@ def _raw(port: int, lines: list[str], send: bytes = b"") -> tuple[int, dict[str, class TestCredit: + """A signed poke over real HTTP and the status each failure mode maps to.""" + def test_valid_poke_credits_and_answers_the_contract_shape( self, port: int, credit: MagicMock ) -> None: + """A valid poke credits once and the reply carries the full contract shape.""" body = _body() status, reply = _post(port, body, _sign(body)) assert status == 200 @@ -136,6 +139,7 @@ class TestCredit: def test_replayed_nonce_is_409_and_credits_once( self, port: int, credit: MagicMock ) -> None: + """Replaying a nonce is refused with 409 and never credits twice.""" body = _body() assert _post(port, body, _sign(body))[0] == 200 status, reply = _post(port, body, _sign(body)) @@ -145,6 +149,7 @@ class TestCredit: assert credit.call_count == 1 def test_stale_timestamp_is_401(self, port: int, credit: MagicMock) -> None: + """A poke with an old timestamp is rejected as stale and not credited.""" body = _body(sent_at_ms=_now_ms() - 600_000) status, reply = _post(port, body, _sign(body)) assert status == 401 @@ -154,12 +159,14 @@ class TestCredit: def test_bad_or_missing_signature_is_401( self, port: int, credit: MagicMock ) -> None: + """A wrong or absent signature is rejected with 401 and not credited.""" body = _body() assert _post(port, body, "00" * 32)[0] == 401 assert _post(port, body, None)[0] == 401 credit.assert_not_called() def test_schema_violation_is_400(self, port: int, credit: MagicMock) -> None: + """An unsupported protocol version is a 400 and not credited.""" body = _body(v=2) status, reply = _post(port, body, _sign(body)) assert status == 400 @@ -172,6 +179,7 @@ class TestCredit: credit: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: + """A crash in crediting gives a 500 that says the regular sync will still credit.""" credit.side_effect = RuntimeError("boom") body = _body() status, reply = _post(port, body, _sign(body)) @@ -182,9 +190,12 @@ class TestCredit: class TestSandbox: + """Sandbox pokes are acknowledged but never credited, and still need a valid signature.""" + def test_sandbox_poke_over_http_is_answered_but_not_credited( self, port: int, credit: MagicMock ) -> None: + """A signed sandbox poke is answered ok without crediting anything.""" body = _body(sandbox=True) status, reply = _post(port, body, _sign(body)) assert (status, reply["ok"], reply["sandbox"]) == (200, True, True) @@ -194,56 +205,7 @@ class TestSandbox: def test_sandbox_with_a_bad_signature_is_still_refused( self, port: int, credit: MagicMock ) -> None: + """A sandbox poke with a bad signature is refused and not flagged as sandbox.""" status, reply = _post(port, _body(sandbox=True), "00" * 32) assert status == 401 assert reply["sandbox"] is False - - -class TestRouting: - def test_unknown_path_is_404(self, port: int, credit: MagicMock) -> None: - body = _body() - status, reply = _post(port, body, _sign(body), path="/v2/other") - assert status == 404 - assert "unknown path" in reply["reason"] - - def test_get_is_405(self, port: int) -> None: - conn = HTTPConnection("127.0.0.1", port, timeout=10) - conn.request("GET", POKE_PATH) - resp = conn.getresponse() - data = json.loads(resp.read()) - conn.close() - assert resp.status == 405 - assert "only POST" in data["reason"] - - def test_missing_content_length_is_411(self, port: int) -> None: - status, _ = _raw(port, ["Host: x"]) - assert status == 411 - - @pytest.mark.parametrize("value", ["abc", "-5", ""]) - def test_malformed_content_length_is_400(self, port: int, value: str) -> None: - status, reply = _raw(port, [f"Content-Length: {value}"]) - assert status == 400 - assert "bad Content-Length" in reply["reason"] - - def test_oversized_body_is_413_without_reading_it(self, port: int) -> None: - status, reply = _raw(port, [f"Content-Length: {MAX_BODY_BYTES + 1}"]) - assert status == 413 - assert str(MAX_BODY_BYTES) in reply["reason"] - - def test_body_at_the_cap_is_read_and_judged_on_its_signature( - self, port: int - ) -> None: - body = b"x" * MAX_BODY_BYTES - assert _post(port, body, _sign(body))[0] == 400 - - def test_truncated_body_is_400(self, port: int) -> None: - status, reply = _raw(port, ["Content-Length: 100"], send=b"0123456789") - assert status == 400 - assert "10 of 100 bytes" in reply["reason"] - - -def test_reply_for_a_refusal_leaves_the_snapshot_null() -> None: - reply = _poke_server._refused("nope") - assert reply["ok"] is False - assert (reply["shutdown"], reply["gaming_budget_minutes"]) == (None, None) - assert reply["reason"] == "nope" diff --git a/screen_locker/tests/test_poke_server_part2.py b/screen_locker/tests/test_poke_server_part2.py new file mode 100644 index 0000000..346aa36 --- /dev/null +++ b/screen_locker/tests/test_poke_server_part2.py @@ -0,0 +1,85 @@ +"""Poke HTTP routing and framing: paths, methods, Content-Length and body limits.""" + +from __future__ import annotations + +from http.client import HTTPConnection +import json +from typing import TYPE_CHECKING + +import pytest + +from screen_locker import _poke_server +from screen_locker._poke_server import MAX_BODY_BYTES, POKE_PATH +from screen_locker.tests.test_poke_server import ( + _body, + _post, + _raw, + _sign, + credit_fixture, + port_fixture, +) + +if TYPE_CHECKING: + from unittest.mock import MagicMock + +__all__ = ["credit_fixture", "port_fixture"] + + +class TestRouting: + """Path, method and Content-Length handling before any body is judged.""" + + def test_unknown_path_is_404(self, port: int, credit: MagicMock) -> None: + """A POST to an unknown path is a 404 naming the path.""" + body = _body() + status, reply = _post(port, body, _sign(body), path="/v2/other") + assert status == 404 + assert "unknown path" in reply["reason"] + + def test_get_is_405(self, port: int) -> None: + """A GET is refused with 405 saying only POST is accepted.""" + conn = HTTPConnection("127.0.0.1", port, timeout=10) + conn.request("GET", POKE_PATH) + resp = conn.getresponse() + data = json.loads(resp.read()) + conn.close() + assert resp.status == 405 + assert "only POST" in data["reason"] + + def test_missing_content_length_is_411(self, port: int) -> None: + """A POST without Content-Length is a 411.""" + status, _ = _raw(port, ["Host: x"]) + assert status == 411 + + @pytest.mark.parametrize("value", ["abc", "-5", ""]) + def test_malformed_content_length_is_400(self, port: int, value: str) -> None: + """A non-numeric, negative or empty Content-Length is a 400.""" + status, reply = _raw(port, [f"Content-Length: {value}"]) + assert status == 400 + assert "bad Content-Length" in reply["reason"] + + def test_oversized_body_is_413_without_reading_it(self, port: int) -> None: + """A declared body over the cap is refused with 413 before reading it.""" + status, reply = _raw(port, [f"Content-Length: {MAX_BODY_BYTES + 1}"]) + assert status == 413 + assert str(MAX_BODY_BYTES) in reply["reason"] + + def test_body_at_the_cap_is_read_and_judged_on_its_signature( + self, port: int + ) -> None: + """A body exactly at the cap is read and then judged on content, not size.""" + body = b"x" * MAX_BODY_BYTES + assert _post(port, body, _sign(body))[0] == 400 + + def test_truncated_body_is_400(self, port: int) -> None: + """A body shorter than its Content-Length is a 400 reporting the byte counts.""" + status, reply = _raw(port, ["Content-Length: 100"], send=b"0123456789") + assert status == 400 + assert "10 of 100 bytes" in reply["reason"] + + +def test_reply_for_a_refusal_leaves_the_snapshot_null() -> None: + """A refusal reply carries no shutdown or gaming-budget snapshot.""" + reply = _poke_server._refused("nope") + assert reply["ok"] is False + assert (reply["shutdown"], reply["gaming_budget_minutes"]) == (None, None) + assert reply["reason"] == "nope" diff --git a/screen_locker/tests/test_poke_wire.py b/screen_locker/tests/test_poke_wire.py index 5280e7b..c44e0a8 100644 --- a/screen_locker/tests/test_poke_wire.py +++ b/screen_locker/tests/test_poke_wire.py @@ -60,45 +60,61 @@ def _rejected(result: PokeRequest | Rejection, status: HTTPStatus) -> Rejection: class TestParseKey: + """Parsing the shared key from its hex text form.""" + def test_decodes_64_hex_chars_with_surrounding_whitespace(self) -> None: + """A 64-char hex key decodes to 32 bytes even with surrounding whitespace.""" assert parse_key(f" {_KEY.hex()}\n") == _KEY def test_wrong_length_says_how_long_it_was(self) -> None: + """A wrong-length key raises an error that reports the length received.""" with pytest.raises(ValueError, match="got 4"): parse_key("abcd") def test_non_hex_of_the_right_length_is_refused(self) -> None: + """A key of the right length but non-hex characters is rejected.""" with pytest.raises(ValueError, match="not valid hex"): parse_key("zz" * 32) class TestVerifySignature: + """HMAC verification of the signature header.""" + def test_matching_mac_verifies(self) -> None: + """A MAC computed with the same key and body verifies.""" assert verify_signature(_KEY, b"body", _sign(b"body")) def test_uppercase_and_padded_header_is_accepted(self) -> None: + """The header is compared case-insensitively and trimmed of whitespace.""" assert verify_signature(_KEY, b"body", f" {_sign(b'body').upper()} ") @pytest.mark.parametrize("header", [None, ""]) def test_missing_header_is_false(self, header: str | None) -> None: + """A missing or empty signature header never verifies.""" assert not verify_signature(_KEY, b"body", header) def test_wrong_key_is_false(self) -> None: + """A MAC made with a different key does not verify.""" assert not verify_signature(_KEY, b"body", _sign(b"body", b"k" * 32)) def test_non_ascii_header_is_false_not_an_exception(self) -> None: + """A non-ASCII header yields False instead of raising.""" assert not verify_signature(_KEY, b"body", "é" * 64) assert not verify_signature(_KEY, b"body", "☃" * 64) class TestNonceCache: + """Replay protection by remembering nonces for the replay window.""" + def test_second_use_inside_window_is_refused(self) -> None: + """Reusing a nonce inside the window is refused.""" cache = NonceCache() assert cache.check_and_add("a", _NOW, _NOW) assert not cache.check_and_add("a", _NOW, _NOW + 1) assert len(cache) == 1 def test_pruned_by_sent_at_after_the_window(self) -> None: + """Entries are pruned by their sent-at time once the window has passed.""" cache = NonceCache() cache.check_and_add("a", _NOW, _NOW) assert cache.check_and_add("b", _NOW, _NOW + REPLAY_WINDOW_MS) @@ -109,6 +125,7 @@ class TestNonceCache: def test_future_stamped_nonce_is_remembered_until_sent_at_plus_window( self, ) -> None: + """A future-stamped nonce is kept until its own sent-at plus the window.""" cache = NonceCache() future = _NOW + 100_000 cache.check_and_add("a", future, _NOW) @@ -116,7 +133,10 @@ class TestNonceCache: class TestCheckRequest: + """The ordered checks applied to an incoming poke request.""" + def test_valid_request_is_parsed(self) -> None: + """A well-formed signed request parses into a PokeRequest.""" result = _check(_doc(sandbox=True)) assert isinstance(result, PokeRequest) assert (result.nonce, result.record_id) == ("n-1", "rec-1") @@ -124,28 +144,34 @@ class TestCheckRequest: assert result.payload == {"date": "2026-10-09"} def test_bad_signature_is_401_and_parses_nothing(self) -> None: + """A bad signature gives 401 before the body is parsed.""" body = b"not json at all" result = check_request(_KEY, body, "00" * 32, NonceCache(), _NOW) assert "signature" in _rejected(result, HTTPStatus.UNAUTHORIZED).reason def test_bad_signature_beats_every_later_check(self) -> None: + """The signature check runs before staleness and other checks.""" body = json.dumps(_doc(sent_at_ms=0)).encode() result = check_request(_KEY, body, None, NonceCache(), _NOW) assert "signature" in _rejected(result, HTTPStatus.UNAUTHORIZED).reason def test_signed_non_utf8_is_400(self) -> None: + """A correctly signed non-UTF-8 body is a 400.""" reason = _rejected(_check(b"\xff\xfe"), HTTPStatus.BAD_REQUEST).reason assert "UTF-8 JSON" in reason def test_signed_invalid_json_is_400(self) -> None: + """A correctly signed body that is not valid JSON is a 400.""" _rejected(_check(b"{nope"), HTTPStatus.BAD_REQUEST) def test_signed_json_array_is_400(self) -> None: + """A signed JSON array instead of an object is a 400.""" reason = _rejected(_check(b"[1]"), HTTPStatus.BAD_REQUEST).reason assert "not a JSON object" in reason @pytest.mark.parametrize("value", [None, "1", True, 1.5]) def test_sent_at_must_be_a_real_int(self, value: object) -> None: + """A sent_at_ms that is not a genuine int is a 400.""" reason = _rejected( _check(_doc(sent_at_ms=value)), HTTPStatus.BAD_REQUEST ).reason @@ -153,24 +179,29 @@ class TestCheckRequest: @pytest.mark.parametrize("skew", [REPLAY_WINDOW_MS + 1, -REPLAY_WINDOW_MS - 1]) def test_stale_or_future_stamp_is_401_naming_the_clock(self, skew: int) -> None: + """A stamp outside the window in either direction is a 401 saying stale.""" result = _check(_doc(sent_at_ms=_NOW - skew)) assert "stale" in _rejected(result, HTTPStatus.UNAUTHORIZED).reason @pytest.mark.parametrize("skew", [REPLAY_WINDOW_MS, -REPLAY_WINDOW_MS]) def test_exactly_the_window_edge_is_still_fresh(self, skew: int) -> None: + """A stamp exactly at the window edge is still accepted.""" assert isinstance(_check(_doc(sent_at_ms=_NOW - skew)), PokeRequest) @pytest.mark.parametrize("nonce", [None, "", 7]) def test_nonce_must_be_a_non_empty_string(self, nonce: object) -> None: + """A missing, empty or non-string nonce is a 400.""" _rejected(_check(_doc(nonce=nonce)), HTTPStatus.BAD_REQUEST) def test_replayed_nonce_is_409(self) -> None: + """Sending the same nonce twice gives a 409 on the second.""" nonces = NonceCache() assert isinstance(_check(_doc(), nonces=nonces), PokeRequest) result = _check(_doc(), nonces=nonces) assert "already used" in _rejected(result, HTTPStatus.CONFLICT).reason def test_staleness_is_checked_before_replay(self) -> None: + """A stale request is a 401 even if its nonce was already used.""" nonces = NonceCache() _check(_doc(), nonces=nonces) stale = _doc(sent_at_ms=_NOW - REPLAY_WINDOW_MS - 1) @@ -178,16 +209,19 @@ class TestCheckRequest: @pytest.mark.parametrize("version", [None, 2, "1", True]) def test_unsupported_version_is_400(self, version: object) -> None: + """Any protocol version other than the integer 1 is a 400.""" reason = _rejected(_check(_doc(v=version)), HTTPStatus.BAD_REQUEST).reason assert "protocol version" in reason @pytest.mark.parametrize("sandbox", [None, "false", 0]) def test_sandbox_must_be_a_bool(self, sandbox: object) -> None: + """A non-boolean sandbox flag is a 400.""" reason = _rejected(_check(_doc(sandbox=sandbox)), HTTPStatus.BAD_REQUEST).reason assert "'sandbox'" in reason @pytest.mark.parametrize("record_id", [None, "", 5]) def test_record_id_must_be_a_non_empty_string(self, record_id: object) -> None: + """A missing, empty or non-string record_id is a 400.""" reason = _rejected( _check(_doc(record_id=record_id)), HTTPStatus.BAD_REQUEST ).reason @@ -195,10 +229,12 @@ class TestCheckRequest: @pytest.mark.parametrize("payload", [None, [], "x"]) def test_payload_must_be_an_object(self, payload: object) -> None: + """A payload that is not a JSON object is a 400.""" reason = _rejected(_check(_doc(payload=payload)), HTTPStatus.BAD_REQUEST).reason assert "'payload'" in reason def test_schema_failure_still_burns_the_nonce(self) -> None: + """A request failing schema checks still consumes its nonce.""" nonces = NonceCache() _rejected(_check(_doc(v=2), nonces=nonces), HTTPStatus.BAD_REQUEST) _rejected(_check(_doc(), nonces=nonces), HTTPStatus.CONFLICT) diff --git a/screen_locker/tests/test_rest_day.py b/screen_locker/tests/test_rest_day.py index 72dbc6d..fddd45e 100644 --- a/screen_locker/tests/test_rest_day.py +++ b/screen_locker/tests/test_rest_day.py @@ -27,21 +27,27 @@ _SAT, _SUN, _MON = date(2026, 10, 10), date(2026, 10, 11), date(2026, 10, 12) @pytest.fixture def key(tmp_path: Path) -> Iterator[Path]: + """Provide a signing key in a temp dir for the duration of the test.""" with signing_key(tmp_path) as path: yield path def rows() -> list[dict[str, object]]: + """Read back the stored rest-day rows.""" return json.loads(_rest_day.REST_DAY_FILE.read_text())["entries"] def write(entries: object) -> None: + """Overwrite the rest-day file with the given entries.""" _rest_day.REST_DAY_FILE.write_text(json.dumps({"entries": entries})) @pytest.mark.usefixtures("key") class TestDeclare: + """declare signs a future rest day, mirrors it to the ledger and enforces the weekly cap.""" + def test_a_future_day_is_signed_and_mirrored_to_the_ledger(self) -> None: + """A future day is signed, stored and mirrored into the earned-time ledger.""" result = declare(_SAT, now=_EVE) assert result.ok assert result.reason == "2026-10-10 is a rest day (1 left in ISO week 41)" @@ -58,12 +64,14 @@ class TestDeclare: @pytest.mark.parametrize("day", [date(2026, 10, 9), date(2026, 10, 1)]) def test_today_and_the_past_are_refused(self, day: date) -> None: + """Today and past days are refused and nothing is written.""" result = declare(day, now=_EVE) assert not result.ok assert "not in the future" in result.reason assert not _rest_day.REST_DAY_FILE.exists() def test_two_per_iso_week_then_refused(self) -> None: + """Only two rest days fit in one ISO week; the next week gets its own two.""" assert declare(_SAT, now=_EVE).ok assert declare(_SUN, now=_EVE).reason.endswith("(0 left in ISO week 41)") assert declare(_SUN, now=_EVE).reason == "2026-10-11 is already a rest day" @@ -73,6 +81,7 @@ class TestDeclare: assert declare(_MON, now=_EVE).ok # the next week has its own two def test_an_unreadable_file_refuses(self, caplog: pytest.LogCaptureFixture) -> None: + """An unreadable rest-day file refuses the declaration with a logged warning.""" _rest_day.REST_DAY_FILE.write_text("{not json") with caplog.at_level(logging.WARNING): result = declare(_SAT, now=_EVE) @@ -81,6 +90,7 @@ class TestDeclare: assert "unreadable" in caplog.text def test_a_failed_write_says_so(self, caplog: pytest.LogCaptureFixture) -> None: + """A failed file write is reported in the refusal reason.""" with ( patch.object( type(_rest_day.REST_DAY_FILE), "write_text", side_effect=OSError("ro") @@ -93,9 +103,12 @@ class TestDeclare: class TestReading: + """rest_day_declarations only trusts signed, well-formed rows.""" + def test_no_key_counts_nothing( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Without a usable signing key no declaration counts and the cause is logged.""" with caplog.at_level(logging.WARNING): assert not rest_day_declarations() assert "signing key" in caplog.text @@ -106,6 +119,7 @@ class TestReading: def test_a_hand_edited_row_is_ignored( self, caplog: pytest.LogCaptureFixture ) -> None: + """A row edited after signing fails its signature and is ignored.""" declare(_SAT, now=_EVE) edited = rows() edited[0]["day"] = "2026-10-14" @@ -130,6 +144,7 @@ class TestReading: def test_malformed_and_foreign_rows_are_ignored( self, caplog: pytest.LogCaptureFixture ) -> None: + """Malformed and foreign rows are skipped with a warning.""" body = { "kind": "rest_day", "day": "soon", @@ -151,6 +166,7 @@ class TestReading: def test_a_file_without_entries_counts_nothing( self, caplog: pytest.LogCaptureFixture ) -> None: + """A rest-day file that is not an entries object counts nothing and warns.""" _rest_day.REST_DAY_FILE.write_text("[]") with caplog.at_level(logging.WARNING): assert not rest_day_declarations() @@ -175,6 +191,8 @@ class TestReading: @pytest.mark.usefixtures("key") class TestCli: + """The declare-rest-day CLI maps outcomes to output text and exit codes.""" + def _run(self, *argv: str, offset: float | None = 0.0) -> int: with ( patch("screen_locker._rest_day_cli._query_ntp_offset", return_value=offset), @@ -185,31 +203,38 @@ class TestCli: return run_declare_rest_day(list(argv)) def test_declare_tomorrow(self, capsys: pytest.CaptureFixture[str]) -> None: + """Declaring tomorrow succeeds and prints the rest day.""" assert not self._run("tomorrow") assert "2026-10-10 is a rest day" in capsys.readouterr().out def test_a_date_and_then_the_list(self, capsys: pytest.CaptureFixture[str]) -> None: + """A date argument declares that day and --list prints it.""" assert not self._run("2026-10-11") assert not self._run("--list") assert capsys.readouterr().out.splitlines()[-1] == "2026-10-11" def test_an_empty_list(self, capsys: pytest.CaptureFixture[str]) -> None: + """Listing with no declarations says so.""" assert not self._run() assert capsys.readouterr().out.strip() == "no rest days declared" def test_a_bad_date(self, capsys: pytest.CaptureFixture[str]) -> None: + """An unparsable date exits with code 2 and an error message.""" assert self._run("someday") == 2 assert capsys.readouterr().out.startswith("error:") def test_no_ntp_refuses(self, capsys: pytest.CaptureFixture[str]) -> None: + """Without a confirmed NTP offset the declaration is refused.""" assert self._run("tomorrow", offset=None) == 1 assert "cannot be confirmed" in capsys.readouterr().out def test_a_skewed_clock_refuses(self, capsys: pytest.CaptureFixture[str]) -> None: + """A clock skewed from NTP refuses the declaration and names the skew.""" assert self._run("tomorrow", offset=86400.0) == 1 assert "off by 86400s" in capsys.readouterr().out def test_today_is_refused(self, capsys: pytest.CaptureFixture[str]) -> None: + """Declaring today is refused as not in the future.""" assert self._run("2026-10-09") == 1 assert "refused: 2026-10-09 is not in the future" in capsys.readouterr().out diff --git a/screen_locker/tests/test_runnerup_walks.py b/screen_locker/tests/test_runnerup_walks.py index 3e00162..5c768b2 100644 --- a/screen_locker/tests/test_runnerup_walks.py +++ b/screen_locker/tests/test_runnerup_walks.py @@ -52,16 +52,21 @@ class Verifier(RunnerUpVerificationMixin): def drop(folder: Path, name: str, body: str) -> None: + """Write a RunnerUp-named TCX export for the test day into the drop folder.""" (folder / f"RunnerUp_Pixel_6a_{_DAY}-{name}.tcx").write_text(body) @pytest.fixture(name="verifier") def verifier_fixture(tmp_path: Path) -> Verifier: + """A Verifier reading a temporary drop directory.""" return Verifier(tmp_path) class TestParse: + """Groups parsing a pulled TCX export into its walking flag and moving time.""" + def test_a_walk_is_flagged_by_its_file_name(self, verifier: Verifier) -> None: + """A file named *_Walking is flagged as a walk even when the TCX sport says Other.""" drop(verifier.folder, "08-00-00_Walking", tcx("Other", 10, "08:00")) (path,) = verifier._find_runnerup_exports_for_date(_DAY) data = verifier._pull_and_parse_tcx(path) @@ -74,7 +79,7 @@ class TestParse: body = tcx("Other", 10, "08:00") def pull(args: list[str]) -> tuple[bool, str]: - + """Fake adb pull that writes the TCX body to the local destination.""" Path(args[2]).write_text(body) return True, "" @@ -84,15 +89,19 @@ class TestParse: assert data["walking"] is True def test_a_failed_pull_is_none(self, verifier: Verifier) -> None: + """When the adb pull fails the parse returns None.""" assert verifier._pull_and_parse_tcx("/sdcard/RunnerUp_x_Walking.tcx") is None class TestVerifyToday: + """Groups the verdict over all of the day's RunnerUp exports.""" + def _verify(self, verifier: Verifier) -> tuple[str, str] | None: with patch("screen_locker._runnerup_verification.today_str", return_value=_DAY): return verifier._verify_runnerup_via_files() def test_walks_summing_to_the_bar_verify(self, verifier: Verifier) -> None: + """Several short walks whose moving time sums past the bar verify together.""" drop(verifier.folder, "08-00-00_Walking", tcx("Other", 15, "08:00")) drop(verifier.folder, "17-00-00_Walking", tcx("Other", 12, "17:00")) result = self._verify(verifier) @@ -111,6 +120,7 @@ class TestVerifyToday: ) def test_a_run_still_verifies_beside_short_walks(self, verifier: Verifier) -> None: + """A long enough run verifies even when the day also has short walks.""" drop(verifier.folder, "07-00-00_Walking", tcx("Other", 10, "07:00")) drop(verifier.folder, "18-00-00_Running", tcx("Running", 45, "18:00", 2.5)) result = self._verify(verifier) @@ -119,6 +129,7 @@ class TestVerifyToday: assert result[1].startswith("Running") def test_a_short_run_outranks_the_walk_shortfall(self, verifier: Verifier) -> None: + """A too-short run is the reported shortfall rather than the walks'.""" drop(verifier.folder, "07-00-00_Walking", tcx("Other", 10, "07:00")) drop(verifier.folder, "18-00-00_Running", tcx("Running", 5, "18:00", 2.5)) result = self._verify(verifier) @@ -128,9 +139,12 @@ class TestVerifyToday: class TestBackfill: + """Groups backfilling the day's RunnerUp activity into log.json.""" + def test_walks_are_logged_as_one_workout( self, verifier: Verifier, tmp_path: Path ) -> None: + """A day's walks are logged as a single workout ending at the last walk, and not again.""" drop(verifier.folder, "08-00-00_Walking", tcx("Other", 15, "08:00")) drop(verifier.folder, "17-00-00_Walking", tcx("Other", 12, "17:00")) log = tmp_path / "log.json" @@ -155,6 +169,7 @@ class TestBackfill: tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: + """Walks short of the bar log nothing and warn that they do not count yet.""" drop(verifier.folder, "08-00-00_Walking", tcx("Other", 20, "08:00")) log = tmp_path / "log.json" with caplog.at_level(logging.WARNING): @@ -165,6 +180,7 @@ class TestBackfill: def test_a_run_records_its_end_time( self, verifier: Verifier, tmp_path: Path ) -> None: + """A logged run records its end time as completed_at.""" drop(verifier.folder, "18-00-00_Running", tcx("Running", 45, "18:00", 2.5)) log = tmp_path / "log.json" assert verifier._try_fill_runnerup_for_date(_DAY, log) is True diff --git a/screen_locker/tests/test_session_sync.py b/screen_locker/tests/test_session_sync.py index 6f1c185..0d78f26 100644 --- a/screen_locker/tests/test_session_sync.py +++ b/screen_locker/tests/test_session_sync.py @@ -40,26 +40,31 @@ class TestValidateSession: """The PC re-derives the verdict rather than trusting the record.""" def test_accepts_a_long_enough_session(self) -> None: + """A 108-minute session with exercises validates and the detail reports the minutes.""" ok, detail = validate_session(_session()) assert ok assert "108 min" in detail def test_rejects_a_session_with_no_exercises(self) -> None: + """A session with an empty exercise list is rejected as having no exercises.""" ok, detail = validate_session(_session(exercises=[])) assert not ok assert "no exercises" in detail def test_rejects_a_non_list_exercises_field(self) -> None: + """An exercises field that is not a list is treated as no exercises.""" ok, detail = validate_session(_session(exercises="Dumbbell Row")) assert not ok assert "no exercises" in detail def test_rejects_a_short_session(self) -> None: + """A 10-minute session is rejected and the detail names its length.""" ok, detail = validate_session(_session(duration_seconds=10 * 60)) assert not ok assert "10 min" in detail def test_rejects_a_non_numeric_duration(self) -> None: + """A non-numeric duration is rejected with a not-a-number reason.""" ok, detail = validate_session(_session(duration_seconds="lots")) assert not ok assert "not a number" in detail @@ -70,6 +75,7 @@ class TestValidateSession: assert ok def test_rejects_just_below_the_accept_threshold(self) -> None: + """A session one second under 25 minutes is rejected without leaking the real bar.""" ok, detail = validate_session(_session(duration_seconds=24 * 60 + 59)) assert not ok # The real bar (25) must never leak into a user-visible string. @@ -81,20 +87,24 @@ class TestBuildSessionEntry: """The entry names the PC as the source, never the phone.""" def test_marks_the_workout_as_pc_verified(self) -> None: + """The entry type is the PC-verified type, never the phone's own claim.""" entry = build_session_entry(_session(), "108 min") assert entry["type"] == PC_WORKOUT_TYPE assert entry["type"] != "phone_verified" def test_describes_the_workout_and_its_outcome(self) -> None: + """The entry source names the workout letter and that all exercises succeeded.""" entry = build_session_entry(_session(), "108 min") assert "workout B" in entry["source"] assert "all succeeded" in entry["source"] def test_reports_a_partial_workout_as_partial(self) -> None: + """A session with succeeded=False is described as partial in the entry source.""" entry = build_session_entry(_session(succeeded=False), "107 min") assert "partial" in entry["source"] def test_carries_the_duration_and_record_id(self) -> None: + """The entry keeps the duration in minutes and the session start as its sync record id.""" entry = build_session_entry(_session(), "108 min") assert entry["duration_minutes"] == "107.9" assert entry["sync_record_id"] == f"{_DATE}T10:39:24.531831" @@ -106,6 +116,7 @@ class TestIngestSessionRecords: def test_appends_a_verified_session_under_its_own_date( self, tmp_path: Path ) -> None: + """A valid session is logged under its own date and counts as one weekly workout.""" log = tmp_path / "log.json" ingested = ingest_session_records(log, [("r1", _session())]) assert ingested == ["r1"] @@ -120,22 +131,26 @@ class TestIngestSessionRecords: assert count_weekly_workouts(log, today=_TODAY) == 1 def test_skips_a_session_with_no_date(self, tmp_path: Path) -> None: + """A session lacking a date is skipped and nothing is ingested.""" payload = _session() del payload["date"] assert not ingest_session_records(tmp_path / "log.json", [("r1", payload)]) def test_skips_a_session_whose_date_is_not_a_string(self, tmp_path: Path) -> None: + """A session whose date is not a string is skipped.""" assert not ( ingest_session_records(tmp_path / "log.json", [("r1", _session(date=7))]) ) def test_skips_a_session_that_fails_validation(self, tmp_path: Path) -> None: + """A session that fails validation is neither ingested nor counted.""" log = tmp_path / "log.json" short = _session(duration_seconds=60) assert not ingest_session_records(log, [("r1", short)]) assert not count_weekly_workouts(log, today=_TODAY) def test_calls_back_for_each_newly_ingested_session(self, tmp_path: Path) -> None: + """The on_ingested callback receives each new entry with the prior log entries.""" seen: list[tuple[dict, list]] = [] ingest_session_records( tmp_path / "log.json", @@ -147,6 +162,7 @@ class TestIngestSessionRecords: assert seen[0][1] == [] def test_does_not_call_back_for_a_duplicate(self, tmp_path: Path) -> None: + """Re-ingesting an already logged record does not fire the callback.""" log = tmp_path / "log.json" records = [("r1", _session())] ingest_session_records(log, records) @@ -161,10 +177,12 @@ class TestPullAllSessionRecords: """Sessions come from every device log, or from none at all.""" def test_returns_nothing_without_a_sync_client(self) -> None: + """With no sync client configured the pull returns an empty list.""" with patch("screen_locker._workout_sync.sync_client", return_value=None): assert pull_all_session_records() == [] def test_returns_the_merged_records(self) -> None: + """The pull returns the merged device records as (record id, session) pairs.""" merged = {"r1": (_session(), object())} with ( patch("screen_locker._workout_sync.sync_client", return_value=object()), diff --git a/screen_locker/tests/test_shutdown_base_replay.py b/screen_locker/tests/test_shutdown_base_replay.py index ecd5c16..818a101 100644 --- a/screen_locker/tests/test_shutdown_base_replay.py +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -55,6 +55,7 @@ def _real_2026_09_13() -> list[dict[str, Any]]: def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path: + """Write a log.json holding the given entries under one day and return its path.""" log_file.write_text(json.dumps({day: entries})) return log_file @@ -64,15 +65,19 @@ class TestEarnedShutdownBonusMinutes: """First credit is worth 2h, every further one 1h.""" def test_no_credits_earn_nothing(self) -> None: + """Zero credits earn zero minutes.""" assert not earned_shutdown_bonus_minutes(0) def test_negative_is_treated_as_nothing(self) -> None: + """A negative credit count earns zero minutes instead of a negative bonus.""" assert not earned_shutdown_bonus_minutes(-1) def test_first_credit_is_two_hours(self) -> None: + """The first credit is worth 120 minutes.""" assert earned_shutdown_bonus_minutes(1) == 120 def test_further_credits_add_one_hour_each(self) -> None: + """Each credit after the first adds 60 minutes, so three earn 240.""" assert earned_shutdown_bonus_minutes(3) == 240 @@ -80,9 +85,11 @@ class TestSyncedCopySharesCredit: """A sync re-ingesting an entry already on the day is the same workout.""" def test_real_day_counts_one_credit(self) -> None: + """The real 2026-09-13 log, a workout plus its synced copy, counts as one credit.""" assert count_day_credits(_DAY, _real_2026_09_13()) == 1 def test_copy_and_original_share_a_key(self) -> None: + """A synced copy and its original produce the same credit key.""" entries = _real_2026_09_13() index = day_workout_index(entries) assert credit_key(_DAY, 0, entries[0], index) == credit_key( @@ -100,6 +107,7 @@ class TestSyncedCopySharesCredit: assert count_day_credits(_DAY, entries) == 2 def test_two_genuine_manual_workouts_still_both_count(self) -> None: + """Two distinct manual workouts on one day each count as a credit.""" entries = [ _manual("manual:2026-09-13T08:00"), _manual("manual:2026-09-13T18:00"), @@ -107,14 +115,17 @@ class TestSyncedCopySharesCredit: assert count_day_credits(_DAY, entries) == 2 def test_without_sibling_index_keys_by_position(self) -> None: + """Without a sibling index the credit key falls back to the entry's position.""" copy = _real_2026_09_13()[1] assert credit_key(_DAY, 3, copy) == ("manual_workout", f"{_DAY}#3") def test_day_workout_index_skips_entries_without_an_id(self) -> None: + """Entries lacking a workout id are left out of the day index.""" entries = [{"workout_data": {"type": "x"}}, _manual("manual:2026-09-13T08:00")] assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 1} def test_day_workout_index_keeps_the_first_position_of_a_repeated_id(self) -> None: + """A repeated workout id maps to the position of its first occurrence.""" entries = [ _manual("manual:2026-09-13T08:00"), _manual("manual:2026-09-13T08:00"), @@ -122,6 +133,7 @@ class TestSyncedCopySharesCredit: assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 0} def test_non_string_sync_id_is_ignored(self) -> None: + """A non-string sync_record_id is ignored, so the entry keeps its positional key.""" entry = _manual("manual:2026-09-13T08:00") entry["workout_data"]["sync_record_id"] = 42 assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0") @@ -132,15 +144,18 @@ class TestTodayCreditCount: """The log -> credit count the reset hands to the registry.""" def test_real_day_counts_one_workout_worth_two_hours(self, tmp_path: Path) -> None: + """The real 2026-09-13 log yields one credit worth 120 minutes.""" log = _write_log(tmp_path / "log.json", _DAY, _real_2026_09_13()) assert today_credit_count(log, _DAY) == 1 assert earned_shutdown_bonus_minutes(today_credit_count(log, _DAY)) == 120 def test_day_absent_from_log_counts_nothing(self, tmp_path: Path) -> None: + """A log with no entry for today yields zero credits.""" log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13()) assert not today_credit_count(log, _DAY) def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None: + """Garbage non-dict entries are skipped while valid ones still count.""" log = tmp_path / "log.json" log.write_text( json.dumps({_DAY: ["garbage", _manual("manual:2026-09-13T08:00")]}) @@ -175,6 +190,7 @@ class TestResetReplaysTodaysCredit: ) def test_without_log_file_writes_plain_base(self, tmp_path: Path) -> None: + """With no log file the reset writes the bare base hour.""" mixin = self._mixin() assert reset_to_base_if_new_day(self._state(tmp_path), mixin) is True base = base_minutes() @@ -183,6 +199,7 @@ class TestResetReplaysTodaysCredit: ) def test_empty_day_writes_plain_base(self, tmp_path: Path) -> None: + """A log with nothing for today makes the reset write the bare base hour.""" log = _write_log(tmp_path / "log.json", "2000-01-01", _real_2026_09_13()) mixin = self._mixin() assert ( diff --git a/screen_locker/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index 8c08d32..d8d10e7 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -64,6 +64,8 @@ def with_extra(monkeypatch: pytest.MonkeyPatch) -> None: class TestFlatBonuses: + """Applying the flat earners' bonuses once each and stamping the state file.""" + def test_both_earned_apply_both_and_keep_both_stamps( self, tmp_path: Path, answers: dict[str, bool | None] ) -> None: @@ -85,6 +87,7 @@ class TestFlatBonuses: def test_only_reading_earned_applies_only_reading( self, tmp_path: Path, answers: dict[str, bool | None] ) -> None: + """When only reading was earned, only its bonus is applied and stamped.""" answers["reading"] = True state = tmp_path / "state.json" mixin = _mixin() @@ -95,9 +98,12 @@ class TestFlatBonuses: @pytest.mark.usefixtures("with_extra") class TestANewlyRegisteredEarner: + """A flat earner added to the registry works with no code change here.""" + def test_reset_includes_and_stamps_it( self, tmp_path: Path, answers: dict[str, bool | None] ) -> None: + """The daily reset counts the new earner in the base and stamps it.""" answers["extra"] = True state = tmp_path / "state.json" mixin = _mixin() @@ -113,6 +119,7 @@ class TestANewlyRegisteredEarner: def test_live_pass_applies_it_once( self, tmp_path: Path, answers: dict[str, bool | None] ) -> None: + """The live pass applies the new earner's bonus a single time.""" answers["extra"] = True state = tmp_path / "state.json" mixin = _mixin() @@ -122,6 +129,7 @@ class TestANewlyRegisteredEarner: assert json.loads(state.read_text()) == {"extra_bonus_date": today_str()} def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None: + """The new earner's signed ledger is read like any existing gate's.""" write_ledger(EXTRA, [credit(EXTRA)]) mixin = _mixin() with signing_key(tmp_path): @@ -147,6 +155,8 @@ class TestANewlyRegisteredEarner: @pytest.mark.usefixtures("leetcode_gate_off") class TestRealLedgers: + """Signed ledgers on disk driving the reset and the live pass without mocks.""" + @pytest.mark.usefixtures("pre_ladder") def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None: """No mock between the ledgers and the written hour.""" @@ -214,6 +224,7 @@ class TestProjectionFollowsTheCut: # The registry in force that day (the tutor cutover switches it), # and from today on its first credits: a never-paid tutor spares # the floor (earned_time 0.6.1). + """The registry's base shutdown in minutes for ``day``.""" earners = _earned.registry(day) starts = _earned.first_credits(earners, day) return earned_time.base_for( diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py index dd46845..5049dac 100644 --- a/screen_locker/tests/test_shutdown_leetcode.py +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -49,15 +49,19 @@ def solved_fixture() -> Iterator[MagicMock]: @pytest.fixture(name="unsolved") def unsolved_fixture() -> Iterator[MagicMock]: + """LeetCode and reading both unanswered, so no flat bonus applies.""" with answering({"leetcode": False, "reading": False}) as m: yield m @pytest.mark.usefixtures("pre_ladder") class TestResetIncludesTheHour: + """The daily reset folds a LeetCode solve into its target and stamps it once.""" + def test_reset_writes_base_plus_leetcode( self, tmp_path: Path, solved: MagicMock ) -> None: + """A reset after a solve writes base plus one hour and stamps the date.""" state = tmp_path / "state.json" mixin = _mixin() assert reset_to_base_if_new_day(state, mixin) is True @@ -72,6 +76,7 @@ class TestResetIncludesTheHour: def test_reset_without_solve_leaves_no_stamp( self, tmp_path: Path, unsolved: MagicMock ) -> None: + """A reset without a solve adds nothing and writes no LeetCode stamp.""" state = tmp_path / "state.json" assert reset_to_base_if_new_day(state, _mixin()) is True assert json.loads(state.read_text()) == {"last_reset_date": today_str()} @@ -89,7 +94,10 @@ class TestResetIncludesTheHour: @pytest.mark.usefixtures("pre_ladder") class TestLivePass: + """The live pass adds the LeetCode hour once and never on a failed write.""" + def test_applies_once_and_stamps(self, tmp_path: Path, solved: MagicMock) -> None: + """The first live pass adds the hour and stamps it; a second adds nothing.""" state = tmp_path / "state.json" state.write_text(json.dumps({"last_reset_date": today_str()})) mixin = _mixin() @@ -104,11 +112,13 @@ class TestLivePass: def test_yesterdays_stamp_does_not_block_today( self, tmp_path: Path, solved: MagicMock ) -> None: + """A stamp from an earlier day does not stop today's bonus.""" state = tmp_path / "state.json" state.write_text(json.dumps({"leetcode_bonus_date": "2000-01-01"})) assert apply_leetcode_bonus_if_new(state, _mixin()) is True def test_no_solve_no_write(self, tmp_path: Path, unsolved: MagicMock) -> None: + """Without a solve the live pass changes nothing.""" mixin = _mixin() assert apply_leetcode_bonus_if_new(tmp_path / "state.json", mixin) is False mixin._adjust_shutdown_time_by.assert_not_called() @@ -138,6 +148,7 @@ class TestLivePass: def test_state_write_failure_is_logged_not_raised( self, tmp_path: Path, solved: MagicMock ) -> None: + """A state file write error does not raise and the bonus still counts as applied.""" state = MagicMock() state.exists.return_value = False state.open.side_effect = OSError("disk full") diff --git a/screen_locker/tests/test_shutdown_minutes.py b/screen_locker/tests/test_shutdown_minutes.py index 09f1ba7..383fed2 100644 --- a/screen_locker/tests/test_shutdown_minutes.py +++ b/screen_locker/tests/test_shutdown_minutes.py @@ -30,12 +30,14 @@ class TestReadShutdownConfigKeys: return read_shutdown_config(conf) def test_minutes_keys_are_read_exactly(self, tmp_path: Path) -> None: + """A file with *_MINUTES keys is read as exact minutes.""" body = "MON_WED_MINUTES=1110\nTHU_SUN_MINUTES=1230\nMORNING_END_MINUTES=330\n" assert self._read(tmp_path, body) == (1110, 1230, 330) def test_legacy_hour_only_file_reads_as_hours_times_sixty( self, tmp_path: Path ) -> None: + """A legacy file with only *_HOUR keys reads as hours times sixty.""" body = "MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=5\n" assert self._read(tmp_path, body) == (1260, 1200, 300) @@ -53,10 +55,12 @@ class TestReadShutdownConfigKeys: assert self._read(tmp_path, body) == (1110, 1200, 300) def test_a_missing_value_is_none(self, tmp_path: Path) -> None: + """A config missing one of the three values reads as None.""" body = "MON_WED_MINUTES=1110\nTHU_SUN_MINUTES=1110\n" assert self._read(tmp_path, body) is None def test_non_numeric_values_and_comments_are_ignored(self, tmp_path: Path) -> None: + """Commented lines and non-numeric values are ignored, falling back to the hour keys.""" body = ( "# MON_WED_MINUTES=1\nMON_WED_MINUTES=oops\nMON_WED_HOUR=21\n" "THU_SUN_HOUR=20\nMORNING_END_HOUR=5\nno-equals-sign\n" @@ -92,6 +96,7 @@ class TestMinutesReachTheHelperScript: def test_a_30_minute_bonus_becomes_18_30( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A 30-minute bonus on 18:00 reaches the helper script as 18:30.""" args = self._run( mock_tk, tmp_path, (1080, 1080, 300), "_adjust_shutdown_time_by", 30 ) @@ -100,6 +105,7 @@ class TestMinutesReachTheHelperScript: def test_the_bonus_is_capped_at_24_00( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A bonus that overshoots is capped at 24:00 in the helper script's arguments.""" args = self._run( mock_tk, tmp_path, (1380, 1380, 300), "_adjust_shutdown_time_by", 90 ) @@ -120,5 +126,6 @@ class TestMinutesReachTheHelperScript: config: tuple[int, int, int], expected: list[str], ) -> None: + """The workout reward is clamped to 23:00 and never lowers an earlier shutdown.""" args = self._run(mock_tk, tmp_path, config, "_adjust_shutdown_time_later") assert args == expected diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py index a06bf5f..c5ee909 100644 --- a/screen_locker/tests/test_shutdown_reading.py +++ b/screen_locker/tests/test_shutdown_reading.py @@ -56,10 +56,14 @@ def answers_fixture() -> Iterator[tuple[dict[str, bool | None], MagicMock]]: class TestBaseHour: + """base_minutes lowers the base evening shutdown once the reading cut starts.""" + def test_day_before_the_cut_keeps_twenty(self) -> None: + """Before the reading cut day the base stays at 20:00.""" assert base_minutes(date(2026, 9, 30)) == 20 * 60 def test_cut_day_is_nineteen(self) -> None: + """On the reading cut day the base is 19:00.""" assert earned_time.READING.penalty_from == date(2026, 10, 1) assert base_minutes(date(2026, 10, 1)) == 19 * 60 @@ -80,14 +84,18 @@ class TestBaseHour: assert base.shutdown_minutes < base_minutes(date(2026, 9, 30)) def test_default_is_the_local_today(self) -> None: + """Called without a day, base_minutes uses the local today.""" assert base_minutes() == base_minutes(datetime.now().astimezone().date()) class TestResetIncludesReading: + """The daily reset folds a paid reading hour in as a term and stamps it.""" + @pytest.mark.usefixtures("pre_ladder") def test_reset_writes_base_plus_reading_and_stamps_it( self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] ) -> None: + """A reading credit raises the reset shutdown by one hour and stamps reading only.""" answers[0]["reading"] = True state = tmp_path / "state.json" mixin = _mixin() @@ -104,6 +112,7 @@ class TestResetIncludesReading: def test_reset_with_both_flat_hours_stamps_both( self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] ) -> None: + """Earning both flat hours adds two hours and stamps both, leaving the live pass idle.""" answers[0]["leetcode"] = True answers[0]["reading"] = True state = tmp_path / "state.json" @@ -161,6 +170,7 @@ class TestResetIncludesReading: answers: tuple[dict[str, bool | None], MagicMock], caplog: pytest.LogCaptureFixture, ) -> None: + """The reset log message spells out every term of the derivation.""" answers[0]["reading"] = True with caplog.at_level("INFO"): reset_to_base_if_new_day(tmp_path / "state.json", _mixin()) @@ -172,79 +182,3 @@ class TestResetIncludesReading: f"Daily base reset: {hhmm(base + 60)} (base {hhmm(base)} + 0h workout + " f"0h LeetCode + 1h reading{later} already earned today)." ) in caplog.text - - -class TestReadingLivePass: - @pytest.mark.usefixtures("pre_ladder") - def test_applies_once_and_stamps( - self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] - ) -> None: - answers[0]["reading"] = True - state = tmp_path / "state.json" - state.write_text(json.dumps({"last_reset_date": today_str()})) - mixin = _mixin() - assert apply_reading_bonus_if_new(state, mixin) is True - assert apply_reading_bonus_if_new(state, mixin) is False - mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) - assert json.loads(state.read_text()) == { - "last_reset_date": today_str(), - "reading_bonus_date": today_str(), - } - - def test_stamped_today_never_reads_the_ledger( - self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] - ) -> None: - state = tmp_path / "state.json" - state.write_text(json.dumps({"reading_bonus_date": today_str()})) - assert apply_reading_bonus_if_new(state, _mixin()) is False - answers[1].assert_not_called() - - def test_no_reading_no_write( - self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] - ) -> None: - mixin = _mixin() - assert apply_reading_bonus_if_new(tmp_path / "state.json", mixin) is False - mixin._adjust_shutdown_time_by.assert_not_called() - - def test_failed_write_leaves_no_stamp_and_warns( - self, - tmp_path: Path, - answers: tuple[dict[str, bool | None], MagicMock], - caplog: pytest.LogCaptureFixture, - ) -> None: - """A stamp without a write would silently forfeit the hour for the day.""" - answers[0]["reading"] = True - state = tmp_path / "state.json" - mixin = _mixin(adjust_ok=False) - with caplog.at_level("WARNING"): - assert apply_reading_bonus_if_new(state, mixin) is False - assert "reading bonus: failed to write" in caplog.text - assert not state.exists() - - def test_cannot_check_warns_and_leaves_no_stamp( - self, - tmp_path: Path, - answers: tuple[dict[str, bool | None], MagicMock], - caplog: pytest.LogCaptureFixture, - ) -> None: - """Unknown is not "no": logged, no hour, and re-asked on the next tick.""" - answers[0]["reading"] = None - state = tmp_path / "state.json" - mixin = _mixin() - with caplog.at_level("WARNING"): - assert apply_reading_bonus_if_new(state, mixin) is False - assert "reading state could not be checked" in caplog.text - mixin._adjust_shutdown_time_by.assert_not_called() - assert not state.exists() - - @pytest.mark.usefixtures("pre_ladder") - def test_applied_hour_is_logged( - self, - tmp_path: Path, - answers: tuple[dict[str, bool | None], MagicMock], - caplog: pytest.LogCaptureFixture, - ) -> None: - answers[0]["reading"] = True - with caplog.at_level("INFO"): - assert apply_reading_bonus_if_new(tmp_path / "state.json", _mixin()) - assert "reading bonus: +1h shutdown time today." in caplog.text diff --git a/screen_locker/tests/test_shutdown_reading_part2.py b/screen_locker/tests/test_shutdown_reading_part2.py new file mode 100644 index 0000000..02b7e91 --- /dev/null +++ b/screen_locker/tests/test_shutdown_reading_part2.py @@ -0,0 +1,119 @@ +"""Live pass for the reading hour: applied once, never stamped on failure. + +Split from test_shutdown_reading.py, which covers the base cut and the reset. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +from screen_locker._day import today_str +from screen_locker._shutdown_base import base_minutes +from screen_locker.tests._earned_fixtures import answering +from screen_locker.tests.test_shutdown_reading import apply_reading_bonus_if_new + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +def _mixin(*, adjust_ok: bool = True) -> MagicMock: + mixin = MagicMock() + mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300) + mixin._write_shutdown_config.return_value = True + mixin._adjust_shutdown_time_by.return_value = adjust_ok + return mixin + + +@pytest.fixture(name="answers") +def answers_fixture() -> Iterator[tuple[dict[str, bool | None], MagicMock]]: + """(answers, earned_today mock): every flat earner says no until set.""" + given: dict[str, bool | None] = {"leetcode": False, "reading": False} + with answering(given) as mock: + yield given, mock + + +class TestReadingLivePass: + """The live pass adds the reading hour once, after the reset.""" + + @pytest.mark.usefixtures("pre_ladder") + def test_applies_once_and_stamps( + self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] + ) -> None: + """A reading credit is applied once and stamped, so the second pass does nothing.""" + answers[0]["reading"] = True + state = tmp_path / "state.json" + state.write_text(json.dumps({"last_reset_date": today_str()})) + mixin = _mixin() + assert apply_reading_bonus_if_new(state, mixin) is True + assert apply_reading_bonus_if_new(state, mixin) is False + mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None) + assert json.loads(state.read_text()) == { + "last_reset_date": today_str(), + "reading_bonus_date": today_str(), + } + + def test_stamped_today_never_reads_the_ledger( + self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] + ) -> None: + """A day already stamped never queries the ledger again.""" + state = tmp_path / "state.json" + state.write_text(json.dumps({"reading_bonus_date": today_str()})) + assert apply_reading_bonus_if_new(state, _mixin()) is False + answers[1].assert_not_called() + + def test_no_reading_no_write( + self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock] + ) -> None: + """With no reading credit the live pass writes nothing.""" + mixin = _mixin() + assert apply_reading_bonus_if_new(tmp_path / "state.json", mixin) is False + mixin._adjust_shutdown_time_by.assert_not_called() + + def test_failed_write_leaves_no_stamp_and_warns( + self, + tmp_path: Path, + answers: tuple[dict[str, bool | None], MagicMock], + caplog: pytest.LogCaptureFixture, + ) -> None: + """A stamp without a write would silently forfeit the hour for the day.""" + answers[0]["reading"] = True + state = tmp_path / "state.json" + mixin = _mixin(adjust_ok=False) + with caplog.at_level("WARNING"): + assert apply_reading_bonus_if_new(state, mixin) is False + assert "reading bonus: failed to write" in caplog.text + assert not state.exists() + + def test_cannot_check_warns_and_leaves_no_stamp( + self, + tmp_path: Path, + answers: tuple[dict[str, bool | None], MagicMock], + caplog: pytest.LogCaptureFixture, + ) -> None: + """Unknown is not "no": logged, no hour, and re-asked on the next tick.""" + answers[0]["reading"] = None + state = tmp_path / "state.json" + mixin = _mixin() + with caplog.at_level("WARNING"): + assert apply_reading_bonus_if_new(state, mixin) is False + assert "reading state could not be checked" in caplog.text + mixin._adjust_shutdown_time_by.assert_not_called() + assert not state.exists() + + @pytest.mark.usefixtures("pre_ladder") + def test_applied_hour_is_logged( + self, + tmp_path: Path, + answers: tuple[dict[str, bool | None], MagicMock], + caplog: pytest.LogCaptureFixture, + ) -> None: + """Applying the reading hour logs it.""" + answers[0]["reading"] = True + with caplog.at_level("INFO"): + assert apply_reading_bonus_if_new(tmp_path / "state.json", _mixin()) + assert "reading bonus: +1h shutdown time today." in caplog.text diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index d2d432a..a57fb29 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -32,18 +32,23 @@ _FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False} def at(hhmm: str) -> float: + """Return the epoch seconds of a local HH:MM on the test day.""" hours, minutes = map(int, hhmm.split(":")) return datetime.combine(_DAY, time(hours, minutes)).astimezone().timestamp() def log_with(tmp_path: Path, entries: list[dict[str, object]]) -> Path: + """Write a log file holding the given entries under the test day.""" log = tmp_path / "log.json" log.write_text(json.dumps({_ISO: entries})) return log class TestTarget: + """The shutdown target derived from the day's inputs, sick or not.""" + def test_a_rest_day_stands_in_for_the_first_workout_only(self) -> None: + """A rest day fills in only the first workout unit and nothing beyond it.""" assert workout_units(0, rest_day=True) == 1 assert workout_units(2, rest_day=True) == 2 assert not workout_units(0, rest_day=False) @@ -57,6 +62,7 @@ class TestTarget: assert not target.lift def test_a_sick_day_has_the_20_00_floor(self) -> None: + """A sick day sits at the 20:00 floor plus the gaming pay of its terms, with no lift.""" target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True)) assert target.resolution.sick is True assert target.minutes == SICK_FLOOR + sum( @@ -66,6 +72,7 @@ class TestTarget: assert not target.lift def test_a_sick_day_stacks_gaming_pay_on_20_00(self) -> None: + """On a sick day credits stack on 20:00 and the result is capped at 23:00.""" busy = dict.fromkeys(_FLAT, True) target = derive(DayInputs(_DAY, busy, workout_credits=1, sick_day=True)) assert target.minutes >= target.earned @@ -82,20 +89,24 @@ class TestTarget: def test_base_minutes_keeps_the_sick_floor_when_the_workout_lock_is_kept( self, ) -> None: + """Keeping the workout lock on a sick day does not lower the base below the sick floor.""" plain = base_minutes(_DAY) assert plain < SICK_FLOOR # reading's cut is in force on this date mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT]) assert base_minutes(_DAY) == SICK_FLOOR def test_not_sick_is_the_plain_sum(self) -> None: + """A normal day's target is exactly the sum of its earned terms.""" target = derive(DayInputs(_DAY, _FLAT)) assert target.resolution.sick is False assert target.minutes == target.earned == target.resolution.shutdown_minutes def test_no_log_counts_no_workouts(self) -> None: + """A missing log file counts as zero workout credits.""" assert not day_credit_count(None, _DAY) def test_gather_reads_every_real_source(self, tmp_path: Path) -> None: + """Gathering reads the log, gate answers and credit time into one target.""" log = log_with( tmp_path, [ diff --git a/screen_locker/tests/test_sick_button.py b/screen_locker/tests/test_sick_button.py index cf952a6..8438468 100644 --- a/screen_locker/tests/test_sick_button.py +++ b/screen_locker/tests/test_sick_button.py @@ -22,15 +22,20 @@ if TYPE_CHECKING: def _button_texts(mock_tk: MagicMock) -> list[str]: + """Return the text of every Button created on the mocked tk module.""" return [str(c.kwargs.get("text", "")) for c in mock_tk.Button.call_args_list] def _label_texts(mock_tk: MagicMock) -> str: + """Return the text of every Label created on the mocked tk module, joined.""" return " ".join(str(c.kwargs.get("text", "")) for c in mock_tk.Label.call_args_list) class TestSeam: + """The _sick seam over the sickdays pool: queries, keep sets and the tap.""" + def test_is_sick_and_sick_dates_read_the_pool(self) -> None: + """is_sick and sick_dates reflect exactly the days marked in the pool.""" assert _sick.is_sick() is False day = mark_sick() old = mark_sick("2026-06-21") @@ -39,6 +44,7 @@ class TestSeam: assert _sick.sick_dates() >= {day, old} def test_stands_down_workout_follows_the_keep_set(self) -> None: + """The workout stands down only on sick days that do not keep the workout locker.""" day = mark_sick(keep=[sickdays.LOCKER_WORKOUT]) other = mark_sick(day + timedelta(days=1), keep=[sickdays.LOCKER_LEETCODE]) assert _sick.stands_down_workout(day) is False @@ -48,11 +54,13 @@ class TestSeam: assert _sick.is_sick(day) is True def test_stands_down_workout_defaults_to_today(self) -> None: + """Without a date, stands_down_workout asks about today.""" assert _sick.stands_down_workout() is False mark_sick() assert _sick.stands_down_workout() is True def test_workout_standdown_dates_drop_the_kept_days(self) -> None: + """Stand-down dates exclude days that keep the workout, unlike sick_dates.""" kept = mark_sick("2026-06-21", keep=[sickdays.LOCKER_WORKOUT]) off = mark_sick("2026-07-24") assert _sick.workout_standdown_dates() >= {off} @@ -73,12 +81,14 @@ class TestSeam: assert sickdays.kept_lockers(day) == {sickdays.LOCKER_DIET} def test_the_status_predicate_follows_the_workout_switch(self) -> None: + """The status-window sick predicate follows the workout keep switch.""" mark_sick(keep=[sickdays.LOCKER_WORKOUT]) assert is_sick_day_today() is False sickdays.set_kept_lockers(sickdays.today(), [sickdays.LOCKER_LEETCODE]) assert is_sick_day_today() is True def test_mark_today_sick(self) -> None: + """mark_today_sick succeeds and today becomes a sick day.""" result = _sick.mark_today_sick() assert result.ok is True assert sickdays.is_sick_day() is True @@ -92,6 +102,7 @@ class TestSeam: assert _sick.stands_down_workout() is True def test_already_sick_is_success(self) -> None: + """Marking an already sick day succeeds and says it was already marked.""" mark_sick() result = _sick.mark_today_sick() assert result.ok is True @@ -106,6 +117,7 @@ class TestSeam: assert _sick.stands_down_workout() is True def test_one_tap_that_cannot_clear_the_keep_set_stays_locked(self) -> None: + """If the keep set cannot be cleared the tap fails with the pool error message.""" mark_sick(keep=(sickdays.LOCKER_WORKOUT,)) with patch.object( sickdays, @@ -117,6 +129,7 @@ class TestSeam: assert "pool unreadable" in result.message def test_a_free_day_is_refused_with_the_reason(self) -> None: + """A free day refuses the sick mark with a message and stays not sick.""" freedays.mark(date.today(), reason="test") result = _sick.mark_today_sick() assert result.ok is False @@ -124,6 +137,7 @@ class TestSeam: assert sickdays.is_sick_day() is False def test_push_runs_sync_quietly_off_the_ui_thread(self) -> None: + """The background push runs sync_quietly on a separate non-daemon thread.""" ran = threading.Event() with patch.object(sickdays, "sync_quietly", side_effect=ran.set): worker = _sick.push_in_background() @@ -133,9 +147,12 @@ class TestSeam: class TestButtonEverywhere: + """Every lock screen that can dead-end offers the I'm sick button.""" + def test_retry_screen_always_offers_it( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """The retry screen shows the I'm sick button.""" locker = create_locker(mock_tk, tmp_path) locker._show_retry_and_sick("nope") assert "I'm sick" in _button_texts(mock_tk) @@ -143,6 +160,7 @@ class TestButtonEverywhere: def test_phone_penalty_screen_offers_it_without_waiting( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """The phone penalty screen shows I'm sick immediately.""" locker = create_locker(mock_tk, tmp_path) locker._show_phone_penalty("no phone") assert "I'm sick" in _button_texts(mock_tk) @@ -150,6 +168,7 @@ class TestButtonEverywhere: def test_relaxed_prompt_and_retry_offer_it( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Both the relaxed-day prompt and its retry screen show I'm sick.""" locker = create_locker_relaxed_day(mock_tk, tmp_path) locker._start_relaxed_day_flow() assert "I'm sick" in _button_texts(mock_tk) @@ -159,9 +178,12 @@ class TestButtonEverywhere: class TestTap: + """What tapping I'm sick does on success and on refusal.""" + def test_success_marks_lifts_pushes_records_and_unlocks( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A successful tap marks sick, applies the floor, pushes, records and closes.""" locker = create_locker(mock_tk, tmp_path) floor, close = MagicMock(), MagicMock() object.__setattr__(locker, "_apply_sick_floor", floor) @@ -180,6 +202,7 @@ class TestTap: def test_refusal_stays_locked_and_says_why( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A refused tap keeps the lock, shows the reason and offers a Back button.""" locker = create_locker(mock_tk, tmp_path) close = MagicMock() object.__setattr__(locker, "close", close) @@ -200,6 +223,7 @@ class TestTap: def test_refusal_on_the_relaxed_prompt_goes_back_to_it( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """From the relaxed prompt a refusal's Back button returns to that prompt.""" locker = create_locker_relaxed_day(mock_tk, tmp_path) locker._show_sick_refused("nope") back = next( diff --git a/screen_locker/tests/test_sick_floor.py b/screen_locker/tests/test_sick_floor.py index a3d2d71..7af594f 100644 --- a/screen_locker/tests/test_sick_floor.py +++ b/screen_locker/tests/test_sick_floor.py @@ -65,7 +65,10 @@ def _target(minutes: int) -> Any: class TestApplySickFloor: + """Groups the live pass that raises tonight to the sick-day target.""" + def test_not_sick_does_nothing(self) -> None: + """On a normal day the sick floor pass leaves the config untouched.""" host = Host((18 * 60, 18 * 60, 300)) assert host._apply_sick_floor() is False assert host.config == (18 * 60, 18 * 60, 300) @@ -79,6 +82,7 @@ class TestApplySickFloor: assert host.config == (_FLOOR, _FLOOR, 300) def test_raises_to_the_derived_target_and_records_no_lift(self) -> None: + """The pass raises both days to the derived target and leaves no lift to absorb.""" mark_sick(_DAY) host = Host((18 * 60, 20 * 60 + 10, 300)) with _target(22 * 60): @@ -87,6 +91,7 @@ class TestApplySickFloor: assert plan_absorb(60) == (60, 0) def test_already_at_or_above_the_target_is_left_alone(self) -> None: + """A config already at the target is not rewritten.""" mark_sick(_DAY) host = Host((21 * 60, 21 * 60, 300)) with _target(21 * 60): @@ -96,18 +101,21 @@ class TestApplySickFloor: def test_unreadable_config_is_logged( self, caplog: pytest.LogCaptureFixture ) -> None: + """An unreadable shutdown config makes the pass return False and warn.""" mark_sick(_DAY) with caplog.at_level(logging.WARNING), _target(_FLOOR): assert Host(None)._apply_sick_floor() is False assert "config unreadable" in caplog.text def test_failed_write_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + """A failed config write makes the pass return False and warn.""" mark_sick(_DAY) with caplog.at_level(logging.WARNING), _target(_FLOOR): assert Host((18 * 60, 18 * 60, 300), ok=False)._apply_sick_floor() is False assert "failed to write" in caplog.text def test_the_target_is_the_derived_sick_resolution(self) -> None: + """sick_target returns the minutes that earned_time's gather resolves for the day.""" mark_sick(_DAY) with patch.object(_sick_floor, "gather") as gather: gather.return_value.minutes = 22 * 60 @@ -116,7 +124,10 @@ class TestApplySickFloor: class TestStackingAtGamingPay: + """Groups credits on a sick day, which land on the derived target rather than stacking.""" + def test_a_workout_on_a_sick_day_lands_on_the_target(self) -> None: + """A workout credit on a sick day sets tonight to the derived target.""" host = Host((_FLOOR, _FLOOR, 300)) mark_sick(_DAY) with _target(22 * 60): @@ -132,6 +143,7 @@ class TestStackingAtGamingPay: assert host.config == (22 * 60, 22 * 60, 300) def test_not_sick_still_adds(self) -> None: + """On a normal day a credit still adds its hour to the config.""" host = Host((19 * 60, 19 * 60, 300)) assert host._adjust_shutdown_time_by(60) is True assert host.config == (20 * 60, 20 * 60, 300) diff --git a/screen_locker/tests/test_sick_panel.py b/screen_locker/tests/test_sick_panel.py index b58bb41..654f118 100644 --- a/screen_locker/tests/test_sick_panel.py +++ b/screen_locker/tests/test_sick_panel.py @@ -14,8 +14,7 @@ import sickdays import sickdays.gui._panel from screen_locker import _ui_flows_sick, _ui_flows_sick_panel -from screen_locker._ui_flows_sick_panel import KEPT_NOTE, LINK_TEXT, build_sick_panel -from screen_locker.tests._sick_helpers import mark_sick +from screen_locker._ui_flows_sick_panel import LINK_TEXT, build_sick_panel from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -93,9 +92,12 @@ def _texts(mock: MagicMock) -> list[str]: class TestLink: + """The "choose lockers" link and how it opens the embedded panel.""" + def test_the_link_sits_next_to_the_one_tap_button( self, mock_tk: MagicMock, locker: ScreenLocker ) -> None: + """The link is wired to open_sick_panel and appears right after the "I'm sick" button.""" locker._show_retry_and_sick("nope") buttons = mock_tk.Button.call_args_list texts = _texts(mock_tk.Button) @@ -108,6 +110,7 @@ class TestLink: def test_opening_builds_todays_panel_on_a_backdrop( self, locker: ScreenLocker, panels: list[FakePanel] ) -> None: + """Opening builds a panel for today, packed and cancellable, on a full-surface backdrop.""" locker.open_sick_panel() panel = panels[0] assert panel.day == sickdays.today() @@ -121,6 +124,7 @@ class TestLink: def test_every_monitor_gets_a_panel_and_the_primary_gets_focus( self, locker: ScreenLocker, panels: list[FakePanel] ) -> None: + """With two outputs each gets a panel and only the primary takes focus.""" locker.open_sick_panel() assert len(panels) == 2 panels[0].parent.tk_focusNext.return_value.focus_set.assert_called_once_with() @@ -129,6 +133,7 @@ class TestLink: def test_reopening_closes_the_previous_panel( self, locker: ScreenLocker, panels: list[FakePanel] ) -> None: + """Opening the panel again destroys the previous backdrop.""" locker.open_sick_panel() first = _backdrop(panels[0]) locker.open_sick_panel() @@ -137,6 +142,7 @@ class TestLink: def test_build_sick_panel_passes_paths_through( self, mock_tk: MagicMock, panels: list[FakePanel], tmp_path: Path ) -> None: + """build_sick_panel forwards its paths and callbacks to the panel unchanged.""" paths = sickdays.Paths.under(tmp_path / "pool") saved, cancel = MagicMock(), MagicMock() build_sick_panel( @@ -147,92 +153,13 @@ class TestLink: assert panels[0].on_cancel is cancel -class TestSaved: - @pytest.fixture(autouse=True) - def _no_mark_no_push(self) -> Iterator[tuple[MagicMock, MagicMock]]: - with ( - patch.object(_ui_flows_sick, "mark_today_sick") as mark, - patch.object(_ui_flows_sick, "push_in_background") as push, - ): - yield mark, push - # The panel already wrote and started its own sync. - mark.assert_not_called() - push.assert_not_called() - - def test_sick_and_standing_down_unlocks_like_the_one_tap( - self, locker: ScreenLocker, panels: list[FakePanel] - ) -> None: - locker.open_sick_panel() - day = mark_sick(keep=[sickdays.LOCKER_LEETCODE]) - entry = sickdays.SickDay( - day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_LEETCODE}) - ) - with patch("screen_locker._startup_checks.record_decision") as record: - panels[0].on_saved(entry) - locker._apply_sick_floor.assert_called_once_with() - assert record.call_args.args[0].reason == "sick_day_marked" - locker.close.assert_called_once_with() - - def test_sick_but_workout_kept_stays_locked_with_a_note( - self, mock_tk: MagicMock, locker: ScreenLocker, panels: list[FakePanel] - ) -> None: - locker.open_sick_panel() - backdrop = _backdrop(panels[0]) - day = mark_sick(keep=[sickdays.LOCKER_WORKOUT]) - entry = sickdays.SickDay( - day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_WORKOUT}) - ) - panels[0].on_saved(entry) - # The 20:00 floor applies on any sick day, keep or not. - locker._apply_sick_floor.assert_called_once_with() - locker.close.assert_not_called() - backdrop.destroy.assert_called_once_with() - assert KEPT_NOTE in _texts(mock_tk.Label) - mock_tk.Button.return_value.focus_set.assert_called_once_with() - back = next( - c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back" - ) - back.kwargs["command"]() - assert locker._sick_overlay is None - - def test_cleared_closes_the_panel_and_leaves_the_lock( - self, locker: ScreenLocker, panels: list[FakePanel] - ) -> None: - locker.open_sick_panel() - backdrop = _backdrop(panels[0]) - entry = sickdays.SickDay(day=sickdays.today(), is_sick=False) - panels[0].on_saved(entry) - backdrop.destroy.assert_called_once_with() - locker._apply_sick_floor.assert_not_called() - locker.close.assert_not_called() - - def test_unchanged_save_closes_the_panel_only( - self, locker: ScreenLocker, panels: list[FakePanel] - ) -> None: - locker.open_sick_panel() - backdrop = _backdrop(panels[0]) - panels[0].on_saved(None) - backdrop.destroy.assert_called_once_with() - locker._apply_sick_floor.assert_not_called() - locker.close.assert_not_called() - - def test_cancel_closes_the_panel_only( - self, locker: ScreenLocker, panels: list[FakePanel] - ) -> None: - locker.open_sick_panel() - backdrop = _backdrop(panels[0]) - assert panels[0].on_cancel is not None - panels[0].on_cancel() - backdrop.destroy.assert_called_once_with() - assert locker._sick_overlay is None - locker._apply_sick_floor.assert_not_called() - locker.close.assert_not_called() - - class TestOneTapUnchanged: + """The one-tap sick button keeps its old behaviour next to the panel.""" + def test_one_tap_still_keeps_nothing_and_builds_no_panel( self, locker: ScreenLocker, panels: list[FakePanel] ) -> None: + """One tap marks sick with nothing kept, pushes, unlocks and builds no panel.""" with patch.object(_ui_flows_sick, "push_in_background") as push: locker.mark_sick_and_unlock() assert sickdays.is_sick_day() is True diff --git a/screen_locker/tests/test_sick_panel_part2.py b/screen_locker/tests/test_sick_panel_part2.py new file mode 100644 index 0000000..e812edc --- /dev/null +++ b/screen_locker/tests/test_sick_panel_part2.py @@ -0,0 +1,117 @@ +"""The lock's reaction to what the embedded sick-day panel reports back.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +import sickdays + +from screen_locker import _ui_flows_sick +from screen_locker._ui_flows_sick_panel import KEPT_NOTE +from screen_locker.tests._sick_helpers import mark_sick +from screen_locker.tests.test_sick_panel import ( + FakePanel, + _backdrop, + _texts, + locker_fixture, + panels_fixture, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from screen_locker.screen_lock import ScreenLocker + +# Fixtures shared with test_sick_panel: re-exported so pytest finds them here. +__all__ = ["locker_fixture", "panels_fixture"] + + +class TestSaved: + """What the lock does for each outcome the panel reports.""" + + @pytest.fixture(autouse=True) + def _no_mark_no_push(self) -> Iterator[tuple[MagicMock, MagicMock]]: + with ( + patch.object(_ui_flows_sick, "mark_today_sick") as mark, + patch.object(_ui_flows_sick, "push_in_background") as push, + ): + yield mark, push + # The panel already wrote and started its own sync. + mark.assert_not_called() + push.assert_not_called() + + def test_sick_and_standing_down_unlocks_like_the_one_tap( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + """A sick day with no workout kept applies the floor, records the decision and unlocks.""" + locker.open_sick_panel() + day = mark_sick(keep=[sickdays.LOCKER_LEETCODE]) + entry = sickdays.SickDay( + day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_LEETCODE}) + ) + with patch("screen_locker._startup_checks.record_decision") as record: + panels[0].on_saved(entry) + locker._apply_sick_floor.assert_called_once_with() + assert record.call_args.args[0].reason == "sick_day_marked" + locker.close.assert_called_once_with() + + def test_sick_but_workout_kept_stays_locked_with_a_note( + self, mock_tk: MagicMock, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + """A sick day that keeps the workout stays locked and shows the kept note.""" + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + day = mark_sick(keep=[sickdays.LOCKER_WORKOUT]) + entry = sickdays.SickDay( + day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_WORKOUT}) + ) + panels[0].on_saved(entry) + # The 20:00 floor applies on any sick day, keep or not. + locker._apply_sick_floor.assert_called_once_with() + locker.close.assert_not_called() + backdrop.destroy.assert_called_once_with() + assert KEPT_NOTE in _texts(mock_tk.Label) + mock_tk.Button.return_value.focus_set.assert_called_once_with() + back = next( + c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back" + ) + back.kwargs["command"]() + assert locker._sick_overlay is None + + def test_cleared_closes_the_panel_and_leaves_the_lock( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + """Clearing the sick day closes the panel without touching the floor or the lock.""" + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + entry = sickdays.SickDay(day=sickdays.today(), is_sick=False) + panels[0].on_saved(entry) + backdrop.destroy.assert_called_once_with() + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() + + def test_unchanged_save_closes_the_panel_only( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + """An unchanged save only closes the panel.""" + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + panels[0].on_saved(None) + backdrop.destroy.assert_called_once_with() + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() + + def test_cancel_closes_the_panel_only( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + """Cancelling only closes the panel and clears the overlay.""" + locker.open_sick_panel() + backdrop = _backdrop(panels[0]) + assert panels[0].on_cancel is not None + panels[0].on_cancel() + backdrop.destroy.assert_called_once_with() + assert locker._sick_overlay is None + locker._apply_sick_floor.assert_not_called() + locker.close.assert_not_called() diff --git a/screen_locker/tests/test_status_streak_section.py b/screen_locker/tests/test_status_streak_section.py index a2c9245..7d2725e 100644 --- a/screen_locker/tests/test_status_streak_section.py +++ b/screen_locker/tests/test_status_streak_section.py @@ -28,11 +28,13 @@ class TestSectionStreak: def test_shows_the_real_week_count_in_success_color( self, mock_tk: MagicMock ) -> None: + """The streak line shows the real week count in the success colour.""" window = _make_window(mock_tk, _snapshot(streak=3)) assert any("3-week streak" in t for t in _texts(mock_tk)) assert window._colors.palette.success in _fg_of(mock_tk, "3-week streak") def test_zero_streak_is_stated_plainly_not_padded(self, mock_tk: MagicMock) -> None: + """A zero streak reads No streak in the muted colour, with no week count.""" window = _make_window(mock_tk, _snapshot(streak=0)) texts = _texts(mock_tk) assert any("No streak" in t for t in texts) @@ -47,16 +49,19 @@ class TestSectionStreak: def test_week_reward_reaches_the_window_in_success_color( self, mock_tk: MagicMock ) -> None: + """A week reward message is shown in the success colour.""" msg = "5 workouts in 2026-W41! 4-week streak" window = _make_window(mock_tk, _snapshot(streak=4, week_rewards=(msg,))) assert msg in _texts(mock_tk) assert window._colors.palette.success in _fg_of(mock_tk, msg) def test_no_reward_text_when_the_week_has_none(self, mock_tk: MagicMock) -> None: + """No reward line appears when the week has no reward.""" _make_window(mock_tk, _snapshot(streak=4)) assert not any("workouts in 20" in t for t in _texts(mock_tk)) def test_a_streak_reset_is_not_painted_as_a_win(self, mock_tk: MagicMock) -> None: + """A streak-reset message is shown in the warning colour, not as a win.""" msg = "Streak reset (was 3 weeks of 5+ workouts)" window = _make_window(mock_tk, _snapshot(streak=0, week_rewards=(msg,))) assert window._colors.palette.warning in _fg_of(mock_tk, msg) diff --git a/screen_locker/tests/test_status_view_part3.py b/screen_locker/tests/test_status_view_part3.py index e4291c1..d391c94 100644 --- a/screen_locker/tests/test_status_view_part3.py +++ b/screen_locker/tests/test_status_view_part3.py @@ -9,7 +9,10 @@ from screen_locker.tests._status_view_helpers import _make_window, _snapshot class TestPollRoutesPhoneCheckOutcome: + """Polling the phone check hands a finished outcome to the result handler.""" + def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None: + """Once the future is done its outcome is passed to the result handler.""" window = _make_window(mock_tk, _snapshot()) mock_future = MagicMock() mock_future.done.return_value = True @@ -23,6 +26,8 @@ class TestPollRoutesPhoneCheckOutcome: class TestWeekScanBackfillResult: + """The status window's reporting of a week-scan backfill from Check Phone.""" + def test_week_scan_backfill_shows_fill_message_and_applies_no_credit( self, mock_tk: MagicMock ) -> None: diff --git a/screen_locker/tests/test_sync_status.py b/screen_locker/tests/test_sync_status.py index aaf193d..a4b01b5 100644 --- a/screen_locker/tests/test_sync_status.py +++ b/screen_locker/tests/test_sync_status.py @@ -91,6 +91,7 @@ class TestPushState: def test_a_non_object_state_file_reads_as_nothing_known( self, tmp_path: Path ) -> None: + """A state file holding a JSON list instead of an object reads as never pushed.""" (tmp_path / "sync_state.json").write_text("[]") assert _gather(tmp_path).pushed is False diff --git a/screen_locker/tests/test_sync_tombstones.py b/screen_locker/tests/test_sync_tombstones.py index be60d5e..1780c52 100644 --- a/screen_locker/tests/test_sync_tombstones.py +++ b/screen_locker/tests/test_sync_tombstones.py @@ -28,6 +28,7 @@ class TestAddTombstone: """The ledger is written once per id, with a clock minted at that moment.""" def test_creates_the_ledger_with_hlc_reason_and_time(self, tmp_path: Path) -> None: + """The first tombstone creates the ledger with a device-stamped HLC, reason and time.""" path = tmp_path / "sync_tombstones.json" assert add_tombstone(_RID, _DEVICE, "duplicate", path) is True entry = json.loads(path.read_text())[_RID] @@ -38,6 +39,7 @@ class TestAddTombstone: def test_second_add_is_a_no_op_and_keeps_the_original_clock( self, tmp_path: Path ) -> None: + """Tombstoning an id twice returns False and leaves the file, and its clock, untouched.""" path = tmp_path / "sync_tombstones.json" add_tombstone(_RID, _DEVICE, "first", path) before = path.read_text() @@ -45,6 +47,7 @@ class TestAddTombstone: assert path.read_text() == before def test_adds_alongside_existing_ids(self, tmp_path: Path) -> None: + """A new tombstone is added next to the ids already in the ledger.""" path = tmp_path / "sync_tombstones.json" add_tombstone(_RID, _DEVICE, "a", path) add_tombstone("manual:2026-01-01T08:00", _DEVICE, "b", path) @@ -55,9 +58,11 @@ class TestTombstoneRecords: """Ledger → crdt_sync Records, loud about anything it cannot push.""" def test_missing_ledger_is_empty(self, tmp_path: Path) -> None: + """A ledger file that does not exist yields no records.""" assert not tombstone_records(tmp_path / "absent.json") def test_ledgered_id_becomes_a_deleted_record(self, tmp_path: Path) -> None: + """A ledgered id becomes a deleted Record carrying its stored HLC.""" path = tmp_path / "sync_tombstones.json" add_tombstone(_RID, _DEVICE, "duplicate", path) record = tombstone_records(path)[_RID] @@ -77,6 +82,7 @@ class TestTombstoneRecords: def test_corrupt_ledger_pushes_nothing_and_warns( self, tmp_path: Path, caplog ) -> None: + """A corrupt ledger file pushes nothing and logs that it is unreadable.""" path = tmp_path / "sync_tombstones.json" path.write_text("{not json") assert not tombstone_records(path) @@ -85,12 +91,14 @@ class TestTombstoneRecords: def test_non_object_ledger_is_ignored_and_warns( self, tmp_path: Path, caplog ) -> None: + """A ledger that is not a JSON object is ignored with a warning.""" path = tmp_path / "sync_tombstones.json" path.write_text("[]") assert not tombstone_records(path) assert "not a JSON object" in caplog.text def test_non_dict_entries_are_dropped(self, tmp_path: Path) -> None: + """Ledger entries that are not dicts are dropped.""" path = tmp_path / "sync_tombstones.json" path.write_text(json.dumps({_RID: "garbage"})) assert not tombstone_records(path) @@ -98,6 +106,7 @@ class TestTombstoneRecords: def test_entry_without_usable_hlc_is_skipped_and_warns( self, tmp_path: Path, caplog ) -> None: + """An entry with no usable deleted_hlc is skipped with a warning.""" path = tmp_path / "sync_tombstones.json" path.write_text(json.dumps({_RID: {"reason": "x"}})) assert not tombstone_records(path) @@ -125,6 +134,7 @@ class TestPushCarriesTombstones: return log_file def test_live_entry_is_pushed_when_not_ledgered(self, tmp_path: Path) -> None: + """An entry absent from the tombstone ledger is pushed as a live record.""" log = records_from_workout_log(self._log(tmp_path)) assert log[_RID].deleted is False @@ -138,6 +148,7 @@ class TestPushCarriesTombstones: def test_tombstone_for_an_id_not_in_the_log_is_still_pushed( self, tmp_path: Path ) -> None: + """A tombstone is pushed even for an id the workout log no longer holds.""" add_tombstone( "manual:2026-01-01T08:00", _DEVICE, diff --git a/screen_locker/tests/test_temperature.py b/screen_locker/tests/test_temperature.py index 7b97a8a..f317165 100644 --- a/screen_locker/tests/test_temperature.py +++ b/screen_locker/tests/test_temperature.py @@ -18,6 +18,7 @@ from screen_locker._temperature import ( def _mock_urlopen_returning(body: bytes) -> MagicMock: + """Build a urlopen stand-in whose response body is the given bytes.""" mock_response = MagicMock() mock_response.read.return_value = body mock_urlopen = MagicMock() @@ -26,7 +27,10 @@ def _mock_urlopen_returning(body: bytes) -> MagicMock: class TestFetchCurrentTempWithStatus: + """fetch_current_temp_with_status turns every outcome into a TemperatureCheck.""" + def test_returns_parsed_temperature_on_success(self) -> None: + """A well-formed wttr.in reply yields the parsed Celsius value.""" body = b'{"current_condition": [{"temp_C": "27"}]}' with patch( "screen_locker._temperature.urllib.request.urlopen", @@ -36,6 +40,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=27.0, timed_out=False) def test_returns_none_on_url_error(self) -> None: + """A URLError gives no temperature and is not a timeout.""" with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=urllib.error.URLError("unreachable"), @@ -44,6 +49,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_timeout_error(self) -> None: + """A TimeoutError from urlopen gives no temperature and is not flagged timed out.""" with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=TimeoutError("timed out"), @@ -52,6 +58,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_os_error(self) -> None: + """An OSError gives no temperature and is not a timeout.""" with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=OSError("network unreachable"), @@ -60,6 +67,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_missing_key(self) -> None: + """A reply lacking current_condition gives no temperature.""" body = b'{"unexpected": "shape"}' with patch( "screen_locker._temperature.urllib.request.urlopen", @@ -69,6 +77,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_empty_current_condition(self) -> None: + """An empty current_condition list gives no temperature.""" body = b'{"current_condition": []}' with patch( "screen_locker._temperature.urllib.request.urlopen", @@ -78,6 +87,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_non_numeric_temp(self) -> None: + """A non-numeric temp_C gives no temperature.""" body = b'{"current_condition": [{"temp_C": "not-a-number"}]}' with patch( "screen_locker._temperature.urllib.request.urlopen", @@ -87,6 +97,7 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_invalid_json(self) -> None: + """A body that is not JSON gives no temperature.""" with patch( "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(b"not json{{{"), @@ -95,7 +106,10 @@ class TestFetchCurrentTempWithStatus: assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_reports_timed_out_when_fetch_exceeds_hard_timeout(self) -> None: + """A fetch slower than the hard timeout is reported as timed out.""" + def _slow_fetch(_city: str) -> float | None: + """Stand-in fetch that sleeps past the hard timeout and then returns 27.0.""" time.sleep(0.3) return 27.0 diff --git a/screen_locker/tests/test_tutor_cutover.py b/screen_locker/tests/test_tutor_cutover.py index 2a6a253..05667f3 100644 --- a/screen_locker/tests/test_tutor_cutover.py +++ b/screen_locker/tests/test_tutor_cutover.py @@ -69,6 +69,7 @@ def _block(hhmm: str) -> dict[str, Any]: def test_the_simulated_cutover_registers_the_tutor(tutor: Any) -> None: + """After the simulated cutover the tutor is a gate earner and not a flat one.""" assert tutor in registry(_CUTOVER) assert tutor in _earned.gate_earners(_CUTOVER) assert tutor not in _earned.flat_earners(_CUTOVER) @@ -79,11 +80,13 @@ class TestConfirmedButNeverCredited: """earned_time 0.7.0: a confirmed tutor is penalised from its first day.""" def test_the_map_says_never(self) -> None: + """The first-credit map says the tutor has never been credited.""" starts = first_credits(registry(_CUTOVER), _CUTOVER) assert starts is not None assert starts["tutor"] is None def test_the_tutor_penalty_is_on_the_gaming_base_unpaid(self) -> None: + """The never-paid tutor leaves the gaming base the same whether or not credits are wired.""" answers = {e.name: 0 for e in registry(_CUTOVER)} starts = first_credits(registry(_CUTOVER), _CUTOVER) wired = derive(DayInputs(_CUTOVER, answers, first_credits=starts)) @@ -115,10 +118,12 @@ class TestTheGraceFloor: """A tutor block is a first task like any other.""" def test_a_tutor_block_starts_the_floor(self, tutor: Any) -> None: + """The first tutor block of the day sets the first-task time.""" write_ledger(tutor, [_block("18:55"), _block("19:10")]) expected = datetime.combine(_CUTOVER, time(18, 55)).astimezone() assert first_done_at(_CUTOVER, None) == expected.timestamp() def test_no_block_no_floor(self, tutor: Any) -> None: + """Without a tutor block there is no first-task time.""" write_ledger(tutor, []) assert first_done_at(_CUTOVER, None) is None diff --git a/screen_locker/tests/test_wake_skip.py b/screen_locker/tests/test_wake_skip.py index 91a2b66..8cebfd3 100644 --- a/screen_locker/tests/test_wake_skip.py +++ b/screen_locker/tests/test_wake_skip.py @@ -31,9 +31,12 @@ def _locker( class TestWakeSkipIntegration: + """How the startup ladder reacts to the wake-alarm carrot.""" + def test_exits_and_banks_the_marker_while_the_carrot_is_in_force( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """While the carrot is in force the lock exits, banks the pending marker and records why.""" with ( patch.object(ScreenLocker, "_save_early_bird_pending") as save, patch("screen_locker._auto_upgrade.record_decision") as record, @@ -49,12 +52,14 @@ class TestWakeSkipIntegration: def test_does_not_exit_without_a_carrot( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """Without a carrot the startup ladder does not exit.""" _locker(tmp_path, carrot=None) mock_sys_exit.assert_not_called() def test_logged_today_takes_precedence( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path ) -> None: + """A workout already logged today wins over the carrot as the exit reason.""" with patch("screen_locker._auto_upgrade.record_decision") as record: _locker(tmp_path, carrot=_SKIP, has_logged=True) mock_sys_exit.assert_called_once_with(0) diff --git a/screen_locker/tests/test_walk_tcx.py b/screen_locker/tests/test_walk_tcx.py index 13f8361..af8f7c2 100644 --- a/screen_locker/tests/test_walk_tcx.py +++ b/screen_locker/tests/test_walk_tcx.py @@ -47,25 +47,33 @@ def walk(seconds: int, speed: float = 1.3) -> ET.Element: class TestIsWalkingExport: + """is_walking_export recognises a walk from the file name or the activity sport.""" + def test_the_file_name_names_the_sport(self) -> None: """RunnerUp's default export writes Sport="Other"; the name says Walking.""" assert is_walking_export("RunnerUp_2026-10-10-08-00-00_Walking.tcx", "Other") def test_the_strava_variant_tags_the_activity(self) -> None: + """The Strava export variant marks the walk on the Activity sport instead of the name.""" assert is_walking_export("RunnerUp_2026-10-10-08-00-00.tcx", "Walking") def test_other_alone_is_not_a_walk(self) -> None: + """The generic Other sport with a non-walking file name is not a walk.""" assert not is_walking_export("RunnerUp_2026-10-10_Running.tcx", "Other") class TestTrackSummary: + """track_summary derives moving time and end time from the GPS track.""" + def test_walking_speed_is_moving(self) -> None: + """Points at walking speed all count as moving time and set the end time.""" summary = track_summary(walk(600)) assert summary["moving_seconds"] == pytest.approx(600) assert summary["activity_id"] == "A1" assert summary["ended_at"] == (_START + timedelta(seconds=600)).timestamp() def test_standing_and_vehicles_are_not_moving(self) -> None: + """Standing-still and vehicle speeds add no moving time.""" assert not track_summary(walk(600, speed=0.1))["moving_seconds"] assert not track_summary(walk(600, speed=8.0))["moving_seconds"] @@ -77,6 +85,7 @@ class TestTrackSummary: def test_unusable_points_are_skipped( self, caplog: pytest.LogCaptureFixture ) -> None: + """Points with no distance, no time or an unparsable time are skipped with a warning.""" points = [ (0, "0.1", None), (5, None, None), # no distance @@ -90,6 +99,7 @@ class TestTrackSummary: assert "unparsable time" in caplog.text def test_no_points_has_no_end(self) -> None: + """A track with no points has zero moving time and no end.""" summary = track_summary(activity([])) assert summary == {"activity_id": "A1", "moving_seconds": 0.0, "ended_at": None} @@ -97,6 +107,7 @@ class TestTrackSummary: def leg( ident: str, minutes: float, ended: float, metres: float = 2000 ) -> dict[str, Any]: + """Build a per-walk summary dict like track_summary produces.""" return { "activity_id": ident, "moving_seconds": minutes * 60, @@ -106,7 +117,10 @@ def leg( class TestWalkDay: + """walk_day aggregates legs into one qualifying day.""" + def test_legs_sum_and_complete_when_the_bar_is_crossed(self) -> None: + """Legs are summed, deduplicated by order, and complete when the bar is crossed.""" day = walk_day([leg("b", 20, 200.0), leg("a", 25, 100.0), leg("c", 5, 300.0)]) assert day.legs == 3 assert day.moving_minutes == pytest.approx(50) @@ -116,6 +130,7 @@ class TestWalkDay: assert day.message() == "Walking: 3 walk(s), 6.0 km, 50 min moving" def test_the_same_walk_from_two_sources_counts_once(self) -> None: + """The same walk reported by two sources is counted once.""" copy = leg("same", 20, 100.0) day = walk_day([copy, dict(copy)]) assert day.legs == 1 @@ -123,6 +138,7 @@ class TestWalkDay: assert day.completed_at is None def test_a_walk_without_id_or_end_still_counts(self) -> None: + """A walk lacking an id or an end time still counts but has no completion time.""" anonymous = {"moving_seconds": 45 * 60} day = walk_day([anonymous]) assert day.legs == 1 @@ -130,11 +146,13 @@ class TestWalkDay: assert day.completed_at is None def test_shortfall_says_what_is_missing(self) -> None: + """The shortfall text states how much moving time is still needed.""" text = walk_shortfall(walk_day([leg("a", 20, 1.0)])) assert text.endswith("need 30+ min moving") def test_walks_share_the_one_workout_accept_bar(self) -> None: # Same advertised-30 / accept-25 bar as every other workout type. + """Walks use the same accept threshold as every other workout type.""" assert not walk_day([leg("a", 24.9, 1.0)]).qualifies day = walk_day([leg("a", 25, 1.0)]) assert day.qualifies diff --git a/screen_locker/tests/test_workday_penalty.py b/screen_locker/tests/test_workday_penalty.py index 2e2d9a0..2394c9d 100644 --- a/screen_locker/tests/test_workday_penalty.py +++ b/screen_locker/tests/test_workday_penalty.py @@ -22,6 +22,7 @@ NOW = datetime(2026, 9, 23, 9, 0).astimezone() def _write(entry: dict[str, object], *, sign: bool = True) -> Path: + """Write the penalty file, signed unless told otherwise, and return its path.""" if sign: entry["hmac"] = compute_entry_hmac(entry) path = _workday_penalty.WORKDAY_PENALTY_FILE @@ -31,6 +32,7 @@ def _write(entry: dict[str, object], *, sign: bool = True) -> Path: @pytest.fixture(autouse=True) def _signing_key(tmp_path: Path) -> None: + """Point the HMAC key at a throwaway file for every test.""" key = tmp_path / "hmac.key" key.write_bytes(b"1" * 32) with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key): @@ -38,17 +40,22 @@ def _signing_key(tmp_path: Path) -> None: class TestVerdict: + """workday_penalty_today reads the stick file and fails closed to no penalty.""" + def test_todays_penalty_applies(self) -> None: + """A signed penalty dated today applies.""" _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is True def test_a_different_dates_penalty_does_not_apply(self) -> None: + """A penalty dated for another day does not apply.""" _write({"penalty_date": "2026-09-24", "issued_date": "2026-09-23"}) assert workday_penalty_today(NOW) is False def test_missing_unreadable_tampered_are_all_no_penalty( self, tmp_path: Path ) -> None: + """A missing, unsigned, unparsable or non-object file means no penalty.""" assert workday_penalty_today(NOW) is False path = _write({"penalty_date": "2026-09-23"}, sign=False) assert workday_penalty_today(NOW) is False @@ -61,6 +68,7 @@ class TestVerdict: def test_default_now_is_used_when_unset(self) -> None: # Not asserting a value -- just that omitting `now` does not raise. + """Omitting now falls back to the current time without raising.""" assert workday_penalty_today() is False @@ -68,6 +76,7 @@ class TestSickDayVoidsThePenalty: """Void when EITHER the missed ring's day OR the penalised day is sick.""" def test_a_sick_penalty_date_voids_it(self) -> None: + """A penalty falling on a sick day is void.""" mark_sick("2026-09-23") _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is False @@ -79,12 +88,14 @@ class TestSickDayVoidsThePenalty: assert workday_penalty_today(NOW) is False def test_an_older_file_without_issued_date_still_applies(self) -> None: + """A file lacking issued_date still applies on its penalty date.""" _write({"penalty_date": "2026-09-23"}) assert workday_penalty_today(NOW) is True def test_a_malformed_issued_date_is_logged_not_sick( self, caplog: pytest.LogCaptureFixture ) -> None: + """A non-date issued_date is logged and does not void the penalty.""" _write({"penalty_date": "2026-09-23", "issued_date": "yesterday"}) assert workday_penalty_today(NOW) is True assert "not a date" in caplog.text @@ -94,21 +105,25 @@ class TestKeptLockersDecideThePenalty: """Ring date -> the alarm switch; penalty date -> the workout lock.""" def test_a_kept_alarm_on_the_ring_day_keeps_the_penalty(self) -> None: + """Keeping the alarm locker on the ring day keeps the penalty in force.""" mark_sick("2026-09-22", keep=[sickdays.LOCKER_ALARM]) _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is True def test_a_kept_workout_on_the_ring_day_still_voids_it(self) -> None: + """Keeping only the workout on the ring day still voids the penalty.""" mark_sick("2026-09-22", keep=[sickdays.LOCKER_WORKOUT]) _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is False def test_a_kept_workout_on_the_penalty_day_keeps_it(self) -> None: + """Keeping the workout locker on the penalty day keeps the penalty in force.""" mark_sick("2026-09-23", keep=[sickdays.LOCKER_WORKOUT]) _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is True def test_a_kept_alarm_on_the_penalty_day_still_voids_it(self) -> None: + """Keeping only the alarm on the penalty day still voids the penalty.""" mark_sick("2026-09-23", keep=[sickdays.LOCKER_ALARM]) _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) assert workday_penalty_today(NOW) is False diff --git a/screen_locker/tests/test_workout_app.py b/screen_locker/tests/test_workout_app.py index 8a73ee7..0bf8910 100644 --- a/screen_locker/tests/test_workout_app.py +++ b/screen_locker/tests/test_workout_app.py @@ -30,6 +30,7 @@ class _FakeStdout: self._it = iter(lines) def readline(self) -> str: + """Return the next fixed line, then empty string at EOF.""" return next(self._it, "") @@ -43,9 +44,11 @@ class _FakeChild: self.waited = False def terminate(self) -> None: + """Record that the child was asked to terminate.""" self.terminated = True def wait(self) -> int: + """Record that the child was waited on and return its exit code.""" self.waited = True return self.returncode @@ -57,9 +60,11 @@ class _Recorder: self.calls: list[str] = [] def release(self) -> None: + """Record a grab release.""" self.calls.append("release") def reacquire(self) -> None: + """Record a grab reacquire.""" self.calls.append("reacquire") @@ -90,6 +95,7 @@ class TestLaunchWorkoutApp: """The handoff, and every way it can fail safely.""" def test_releases_the_grab_once_the_app_is_ready(self) -> None: + """The grab is released once the ready line arrives and reacquired afterwards.""" recorder = _Recorder() result = _run([f"{READY_MARKER}\n"], recorder) assert result.launched @@ -111,6 +117,7 @@ class TestLaunchWorkoutApp: assert "exited before signalling ready" in result.reason def test_terminates_an_app_that_never_signals_ready(self) -> None: + """A child that never signals ready is terminated.""" recorder = _Recorder() child = _FakeChild([]) _run([], recorder, child=child) @@ -160,6 +167,7 @@ class TestLaunchWorkoutApp: assert "-15" in result.reason def test_waits_for_the_app_to_exit(self) -> None: + """The launcher waits for the app process to exit.""" recorder = _Recorder() child = _FakeChild([f"{READY_MARKER}\n"]) _run([], recorder, child=child) @@ -176,6 +184,7 @@ class TestLaunchWorkoutApp: assert child.terminated def test_says_so_when_the_app_is_not_built(self) -> None: + """A missing built binary is reported as not built and the grab is untouched.""" recorder = _Recorder() result = launch_workout_app( GrabHandoff(release=recorder.release, reacquire=recorder.reacquire), @@ -193,6 +202,7 @@ class TestWorkoutAppBinary: """Locating the built bundle.""" def test_returns_none_when_nothing_is_built(self, tmp_path: Path) -> None: + """No built bundle yields None.""" assert workout_app_binary(tmp_path) is None def test_defaults_to_the_real_repo_root(self) -> None: @@ -201,6 +211,7 @@ class TestWorkoutAppBinary: assert found is None or found.name == "workout_app" def test_prefers_release_over_debug(self, tmp_path: Path) -> None: + """A release bundle wins over a debug one.""" base = tmp_path / "stronglift_replacement/workout_app/build/linux/x64" for flavour in ("debug", "release"): binary = base / flavour / "bundle" / "workout_app" @@ -209,6 +220,7 @@ class TestWorkoutAppBinary: assert workout_app_binary(tmp_path).parent.parent.name == "release" def test_falls_back_to_debug(self, tmp_path: Path) -> None: + """With only a debug bundle built, that one is used.""" binary = ( tmp_path / "stronglift_replacement/workout_app/build/linux/x64/debug/bundle" diff --git a/screen_locker/tests/test_workout_handoff.py b/screen_locker/tests/test_workout_handoff.py index b8ecd3d..ce4fd58 100644 --- a/screen_locker/tests/test_workout_handoff.py +++ b/screen_locker/tests/test_workout_handoff.py @@ -24,6 +24,7 @@ class _FakeRecovery: self.events: list[str] = [] def stop(self) -> None: + """Stop the watchdog and record the stop.""" self.running = False self.events.append("stop") @@ -40,6 +41,7 @@ class _FakeLock: class _Root: def grab_release(self) -> None: + """Record the release, raising TclError when told no grab is held.""" outer.events.append("grab_release") if outer._release_raises: msg = "no grab to release" @@ -52,10 +54,12 @@ class _FakeLock: self.events.append(f"held={held}") def grab_input(self) -> None: + """Record a grab and restart the watchdog, as the real lock does.""" self.events.append("grab_input") self.recovery.running = True def close(self) -> None: + """Mark the lock window as closed.""" self.closed = True @@ -71,6 +75,7 @@ class TestLockGrabHandoff: assert not lock.recovery.running def test_reacquire_restores_the_grab_and_the_watchdog(self) -> None: + """Reacquiring grabs input again and restarts the watchdog after a release.""" handoff = lock_grab_handoff(lock := _FakeLock()) handoff.release() handoff.reacquire() @@ -96,6 +101,7 @@ class TestStartWorkout: """The button handler never raises onto a screen with no way out.""" def test_reports_a_failed_launch_as_a_message(self, monkeypatch: object) -> None: + """A failed launch comes back as a message with the reason, not an exception.""" class _Result: launched = False @@ -109,6 +115,7 @@ class TestStartWorkout: assert "not built" in message def test_reports_a_finished_run(self, monkeypatch: object) -> None: + """A finished run tells the user the result is being checked.""" class _Result: launched = True diff --git a/screen_locker/tests/test_workout_ledger.py b/screen_locker/tests/test_workout_ledger.py index 98568d3..b5749e2 100644 --- a/screen_locker/tests/test_workout_ledger.py +++ b/screen_locker/tests/test_workout_ledger.py @@ -32,12 +32,14 @@ _ISO = _DAY.isoformat() def local(hhmm: str, day: date = _DAY) -> float: + """Local-time timestamp of an HH:MM wall-clock moment on the given day.""" hours, minutes = map(int, hhmm.split(":")) return datetime.combine(day, time(hours, minutes)).astimezone().timestamp() @pytest.fixture(name="key") def key_fixture(tmp_path: Path) -> Iterator[Path]: + """Provide a signing key in a temp dir for the duration of the test.""" with signing_key(tmp_path) as path: yield path @@ -45,19 +47,25 @@ def key_fixture(tmp_path: Path) -> Iterator[Path]: def entry( data: dict[str, Any], stamp: str = "2026-10-04T20:00:00+00:00", **extra: Any ) -> dict[str, Any]: + """Build a ledger-source log entry around the given workout data.""" return {"timestamp": stamp, "workout_data": data, **extra} def ledger() -> list[dict[str, Any]]: + """Read back the entries stored in the signed ledger file.""" return json.loads(ledger_path().read_text())["entries"] class TestEntryDoneAt: + """entry_done_at picks the true end time of a workout from each entry shape.""" + def test_the_tcx_end_wins(self) -> None: + """A runnerup entry's TCX completed_at is used as the done time.""" done = entry({"type": "runnerup_verified", "completed_at": 123.5}) assert entry_done_at(done, _DAY) == 123.5 def test_a_numeric_string_and_iso_are_read(self) -> None: + """completed_at is accepted as a numeric string or as an ISO timestamp.""" assert entry_done_at(entry({"completed_at": "77.5"}), _DAY) == 77.5 iso = entry({"completed_at": "2026-10-04T10:00:00+00:00"}) assert ( @@ -66,10 +74,12 @@ class TestEntryDoneAt: ) def test_a_manual_end_time_is_on_its_day(self) -> None: + """A manual workout's end_time is read as a clock time on the entry's own day.""" manual = entry({"type": "manual_workout", "end_time": "18:40"}) assert entry_done_at(manual, _DAY) == local("18:40") def test_a_pc_session_is_its_start_plus_duration(self) -> None: + """A PC session ends at its sync-record start plus its duration.""" session = entry( { "type": "pc_workout_verified", @@ -82,6 +92,7 @@ class TestEntryDoneAt: def test_a_session_without_a_duration_falls_back_to_logging( self, caplog: pytest.LogCaptureFixture ) -> None: + """A PC session with no duration falls back to the log timestamp and warns.""" session = entry( {"type": "pc_workout_verified", "sync_record_id": "2026-10-04T15:42:00"} ) @@ -91,6 +102,7 @@ class TestEntryDoneAt: assert "no usable duration" in caplog.text def test_nothing_usable_is_none(self, caplog: pytest.LogCaptureFixture) -> None: + """An entry with no usable time yields None and logs a warning.""" bare = {"workout_data": "?", "timestamp": "never"} with caplog.at_level(logging.WARNING): assert entry_done_at(bare, _DAY) is None @@ -98,8 +110,11 @@ class TestEntryDoneAt: class TestCreditRow: + """credit_row maps workout entries to signed credit rows.""" + def test_each_source_is_named(self) -> None: # No workout_id on the entry: the credit slot names the row. + """Each workout type is credited under its own source name and entry id.""" cases = { "runnerup_verified": ("runnerup_tcx", f"runnerup_verified:{_ISO}#0"), "phone_verified": ("stronglifts", f"stronglifts:{_ISO}"), @@ -112,9 +127,11 @@ class TestCreditRow: assert row["entry_id"] == entry_id def test_a_non_credit_has_no_row(self) -> None: + """An entry that is not a credit (a relaxed-day skip) produces no row.""" assert credit_row(_ISO, [entry({"type": "relaxed_day_skip"})], 0) is None def test_both_stronglifts_paths_share_one_row_and_the_real_end(self) -> None: + """The phone and PC StrongLifts entries collapse to one row with the real end time.""" phone = entry({"type": "phone_verified"}, workout_id="phone_verified:x") pc = entry( { @@ -133,12 +150,14 @@ class TestCreditRow: def test_a_credit_with_no_time_writes_no_row( self, caplog: pytest.LogCaptureFixture ) -> None: + """A credit with no usable time writes no row and logs why.""" with caplog.at_level(logging.WARNING): row = credit_row(_ISO, [{"workout_data": {"type": "phone_verified"}}], 0) assert row is None assert "no usable time" in caplog.text def test_a_rest_row_is_stamped_at_its_midnight(self) -> None: + """A rest-day row is stamped at that day's local midnight with its declared time.""" row = rest_row(_DAY, 5.0) assert row["detail"] == { "completed_at": str(local("00:00")), @@ -148,7 +167,10 @@ class TestCreditRow: class TestAppend: + """append_rows writes signed rows to the ledger and guards its integrity.""" + def test_rows_are_signed_once(self, key: Path) -> None: + """Rows are signed, verified and not appended twice.""" del key row = credit_row(_ISO, [entry({"type": "phone_verified"})], 0) assert row is not None @@ -158,6 +180,7 @@ class TestAppend: assert earned_time.verified(stored, KEY) def test_no_key_writes_nothing(self, caplog: pytest.LogCaptureFixture) -> None: + """Without a signing key nothing is written and a warning is logged.""" with caplog.at_level(logging.WARNING): assert not append_rows([rest_row(_DAY, 1.0)]) assert "NOT updated" in caplog.text @@ -167,6 +190,7 @@ class TestAppend: def test_a_malformed_ledger_is_left_alone( self, caplog: pytest.LogCaptureFixture ) -> None: + """A ledger file that is not in the expected shape is not overwritten.""" ledger_path().parent.mkdir(parents=True) ledger_path().write_text("[]") with caplog.at_level(logging.WARNING): @@ -175,6 +199,7 @@ class TestAppend: assert ledger_path().read_text() == "[]" def test_new_rows_dedups_within_and_against_the_ledger(self) -> None: + """new_rows drops duplicates both within the batch and against existing rows.""" a, b = rest_row(_DAY, 1.0), rest_row(date(2026, 10, 5), 1.0) assert new_rows([a, b, dict(b)], [a]) == [b] @@ -194,4 +219,5 @@ class TestAppend: def test_the_ledger_lives_under_the_redirected_home() -> None: + """The ledger path follows the redirected home directory, never the real one.""" assert ledger_path().is_relative_to(_workout_ledger._earned.LEDGER_HOME) diff --git a/screen_locker/tests/test_workout_ledger_cli.py b/screen_locker/tests/test_workout_ledger_cli.py index 5920b58..b03a2b0 100644 --- a/screen_locker/tests/test_workout_ledger_cli.py +++ b/screen_locker/tests/test_workout_ledger_cli.py @@ -24,20 +24,25 @@ _ISO = _DAY.isoformat() @pytest.fixture def key(tmp_path: Path) -> Iterator[Path]: + """Provide a signing key in a temp dir for the duration of the test.""" with signing_key(tmp_path) as path: yield path def entry(data: dict[str, Any]) -> dict[str, Any]: + """Build a log entry around the given workout data.""" return {"timestamp": "2026-10-04T20:00:00+00:00", "workout_data": data} def ledger() -> list[dict[str, Any]]: + """Read back the entries stored in the signed ledger file.""" return json.loads(ledger_path().read_text())["entries"] @pytest.mark.usefixtures("key") class TestBackfill: + """The backfill CLI rebuilds missing ledger rows from the workout log and rest days.""" + def _log(self, tmp_path: Path) -> Path: log = tmp_path / "log.json" log.write_text( @@ -56,6 +61,7 @@ class TestBackfill: return log def test_rows_for_a_range_and_its_rest_days(self, tmp_path: Path) -> None: + """Backfill yields rows for the log entries in range plus its declared rest days.""" with patch("screen_locker._rest_day.datetime") as clock: clock.now.return_value = datetime(2026, 10, 1).astimezone() assert declare(date(2026, 10, 6)).ok @@ -70,6 +76,7 @@ class TestBackfill: def test_dry_run_prints_and_writes_nothing( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: + """A dry run reports missing rows per source and writes no ledger.""" argv = ["--dry-run", "--from", _ISO, "--to", "2026-10-05"] assert not run_backfill(self._log(tmp_path), argv) out = capsys.readouterr().out @@ -80,6 +87,7 @@ class TestBackfill: def test_a_real_run_is_idempotent( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: + """Running the backfill twice adds rows only the first time.""" log = self._log(tmp_path) assert not run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) assert not run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) @@ -90,6 +98,7 @@ class TestBackfill: assert "(present)" in capsys.readouterr().out def test_default_is_today(self, tmp_path: Path) -> None: + """With no range given, the backfill covers today only.""" with patch("screen_locker._workout_ledger_cli.today_str", return_value=_ISO): assert not run_backfill(self._log(tmp_path), []) assert len(ledger()) == 1 diff --git a/screen_locker/tests/test_workout_session.py b/screen_locker/tests/test_workout_session.py index fc9b737..b5c5b1d 100644 --- a/screen_locker/tests/test_workout_session.py +++ b/screen_locker/tests/test_workout_session.py @@ -31,6 +31,7 @@ class _FakeAfter: return object() def drain(self, limit: int = 5000) -> None: + """Run queued callbacks in order until none are left or the limit is hit.""" for _ in range(limit): if not self.queue: return @@ -42,6 +43,7 @@ class _FakeStdout: self._chunks = chunks def readline(self) -> str: + """Return the next canned chunk, or an empty string when exhausted.""" return self._chunks.pop(0) if self._chunks else "" @@ -54,6 +56,7 @@ class _FakeChild: self._exits_after = exits_after def poll(self) -> int | None: + """Report the exit code once the configured number of polls has passed.""" self._polls += 1 if self._polls >= self._exits_after: self.returncode = 0 @@ -61,6 +64,7 @@ class _FakeChild: return None def terminate(self) -> None: + """Record that the child was terminated and set a SIGTERM exit code.""" self.terminated = True self.returncode = -15 @@ -85,6 +89,7 @@ class TestWorkoutSession: """Each phase hands control back to the event loop.""" def test_releases_the_grab_only_after_the_ready_line(self) -> None: + """The grab is released only after the ready marker, and before it is reacquired.""" after, calls = _FakeAfter(), [] child = _FakeChild([f"{READY_MARKER}\n"], exits_after=2) _session(child, after, calls).start() @@ -94,12 +99,14 @@ class TestWorkoutSession: assert calls.index("release") < calls.index("reacquire") def test_never_releases_when_the_app_dies_before_ready(self) -> None: + """If the app dies before signalling ready the grab is never released.""" after, calls = _FakeAfter(), [] _session(_FakeChild([]), after, calls).start() after.drain() assert "release" not in calls def test_reacquires_when_the_app_exits(self) -> None: + """When the app exits the grab is reacquired and a closed status is shown.""" after, calls = _FakeAfter(), [] child = _FakeChild([f"{READY_MARKER}\n"], exits_after=3) _session(child, after, calls).start() @@ -129,6 +136,7 @@ class TestWorkoutSession: assert "reacquire" not in calls def test_times_out_without_ever_blocking(self) -> None: + """A silent app is terminated after the retry budget without releasing the grab.""" after, calls = _FakeAfter(), [] child = _FakeChild(["noise\n"] * 10, exits_after=99) session = _session(child, after, calls) @@ -139,6 +147,7 @@ class TestWorkoutSession: assert "release" not in calls def test_says_so_when_the_app_is_not_built(self) -> None: + """A missing workout binary is reported in the status text.""" after, calls = _FakeAfter(), [] session = WorkoutSession( GrabHandoff(release=lambda: None, reacquire=lambda: None), @@ -155,6 +164,7 @@ class TestWorkoutSession: assert any("not built" in c for c in calls) def test_handles_a_missing_stdout_pipe(self) -> None: + """A child without a stdout pipe never causes the grab to be released.""" after, calls = _FakeAfter(), [] child = _FakeChild([]) child.stdout = None @@ -191,6 +201,7 @@ class TestSessionEdges: assert child.terminated def test_abort_with_no_child_is_harmless(self) -> None: + """Aborting a never-started session just reports the app as closed.""" after, calls = _FakeAfter(), [] session = _session(_FakeChild([]), after, calls) session.abort() # never started @@ -231,6 +242,7 @@ class TestDemoEscapeFlag: assert "--demo-escape" not in self._argv_for(demo_escape=False) def test_demo_arms_the_hatch(self) -> None: + """With demo_escape on, the app is launched with --demo-escape in lock mode.""" argv = self._argv_for(demo_escape=True) assert argv[-1] == "--demo-escape" assert "--lock-mode" in argv