mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
4a36b6b264
commit
aa3e290391
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
118
screen_locker/tests/test_earned_part2.py
Normal file
118
screen_locker/tests/test_earned_part2.py
Normal 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
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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")):
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
149
screen_locker/tests/test_manual_workout_dialog_part2.py
Normal file
149
screen_locker/tests/test_manual_workout_dialog_part2.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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("{}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"
|
||||
|
||||
85
screen_locker/tests/test_poke_server_part2.py
Normal file
85
screen_locker/tests/test_poke_server_part2.py
Normal 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"
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
119
screen_locker/tests/test_shutdown_reading_part2.py
Normal file
119
screen_locker/tests/test_shutdown_reading_part2.py
Normal 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
|
||||
@ -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,
|
||||
[
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
117
screen_locker/tests/test_sick_panel_part2.py
Normal file
117
screen_locker/tests/test_sick_panel_part2.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user