Document every test, split files at the cap

Real one-line docstrings stating what each test pins down. Files that
would pass 250 lines moved whole classes into _part2 siblings
(sick_panel, earned, poke_server, shutdown_reading,
manual_workout_dialog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 21:09:21 +01:00
parent 4a36b6b264
commit aa3e290391
66 changed files with 1296 additions and 428 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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,

View File

@ -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",
{

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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")):

View File

@ -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 (

View File

@ -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

View File

@ -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")

View File

@ -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())

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

@ -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
)

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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("{}")

View File

@ -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)

View File

@ -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())])

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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"

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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()),

View File

@ -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 (

View File

@ -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(

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
[

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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