screen-locker/screen_locker/tests/test_extra_benefits.py
Krzysztof kuhy Rudnicki 74a8bd7529 Add auto-fill RunnerUp scan, carrot bonuses, and --status interface
- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py),
  split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date
  to keep cyclomatic complexity ≤10
- Generalise TCX lookup to any date in the ISO week (was today-only); all gap
  days Mon→today auto-filled on every startup and 08:30 timer firing
- Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout
  minimum, capped at midnight (hour=24)
- Add _shutdown_base.py: daily reset of shutdown config to a stored base so
  the bonus doesn't silently accumulate across days
- Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits
  for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks
- Add --status mode (_status.py): non-locking CLI view showing per-day
  breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak,
  skip credits, and early-bird status
- Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill
  pushes weekly count above the minimum
- Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck,
  codespell, max-file-length); 508 tests at 100% branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX
2026-06-28 08:08:35 +02:00

287 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for _extra_benefits module (streak, skip credits, EB extension)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._extra_benefits import (
_load_state,
_save_state,
consume_skip_credit,
current_streak,
has_extended_early_bird,
has_skip_credit,
process_week_transition,
)
if TYPE_CHECKING:
from pathlib import Path
class TestLoadState:
"""Tests for _load_state helper."""
def test_returns_empty_dict_when_file_missing(self, tmp_path: Path) -> None:
"""Non-existent file returns empty dict (line 29 — the missing-file branch)."""
result = _load_state(tmp_path / "nonexistent.json")
assert result == {}
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
assert _load_state(f) == {"skip_credits": 3}
def test_returns_empty_on_oserror(self) -> None:
"""OSError during read is caught and returns empty dict (lines 33-34)."""
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_path.open.side_effect = OSError("read fail")
assert _load_state(mock_path) == {}
def test_returns_empty_on_invalid_json(self, tmp_path: Path) -> None:
"""Corrupt JSON is caught and returns empty dict (lines 33-34)."""
f = tmp_path / "state.json"
f.write_text("not-json{{{")
assert _load_state(f) == {}
class TestSaveState:
"""Tests for _save_state helper."""
def test_saves_state_to_file(self, tmp_path: Path) -> None:
"""Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json"
_save_state(f, {"skip_credits": 2})
assert json.loads(f.read_text())["skip_credits"] == 2
def test_logs_warning_on_oserror(self) -> None:
"""OSError during write is caught as warning, does not raise (lines 42-43)."""
mock_path = MagicMock()
mock_path.open.side_effect = OSError("write fail")
_save_state(mock_path, {"key": "val"}) # must not raise
class TestProcessWeekTransition:
"""Tests for process_week_transition."""
_PAST_WEEK = "2020-W01"
def test_returns_empty_when_already_processed_this_week(
self, tmp_path: Path
) -> None:
"""Early return when ISO week already processed (line 63)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_processed_iso_week": f"{year}-W{week:02d}"}))
assert process_week_transition(tmp_path / "log.json", f) == []
def test_awards_credits_for_5plus_workouts(self, tmp_path: Path) -> None:
"""5+ workouts in previous week: streak += 1, skip_credits += extra (lines 87-96)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
"skip_credits": 0,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=6
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert len(rewards) >= 1
assert "+2 skip credit" in rewards[0]
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1
assert state["skip_credits"] == 2 # 6 4
def test_awards_milestone_bonus_at_4_week_streak(self, tmp_path: Path) -> None:
"""Streak reaches multiple of 4: +1 bonus skip credit (lines 97-99)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 3,
"skip_credits": 0,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert any("milestone" in r for r in rewards)
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 4
assert state["skip_credits"] == 2 # 1 extra + 1 milestone
def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None:
"""5+ workouts mark current ISO week as extended EB (line 91-92)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
process_week_transition(tmp_path / "log.json", f)
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
state = json.loads(f.read_text())
assert f"{year}-W{week:02d}" in state["extended_early_bird_iso_weeks"]
def test_resets_streak_for_fewer_than_5_workouts(self, tmp_path: Path) -> None:
"""< 5 workouts in previous week resets streak and logs message (lines 100-103)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 2,
"skip_credits": 3,
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=3
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert any("Streak reset" in r for r in rewards)
assert json.loads(f.read_text())["consecutive_5plus_weeks"] == 0
def test_no_reset_message_when_streak_was_zero(self, tmp_path: Path) -> None:
"""Zero streak + < 5 workouts: no reset message (line 101 branch False)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=2
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert not any("Streak reset" in r for r in rewards)
def test_fresh_state_file_processed_on_new_week(self, tmp_path: Path) -> None:
"""No state file: _load_state returns {} and transition runs (covers line 29)."""
f = tmp_path / "nonexistent.json"
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=4
):
process_week_transition(tmp_path / "log.json", f)
assert f.exists() # state file created
def test_duplicate_eb_week_not_added_twice(self, tmp_path: Path) -> None:
"""Current week already in EB list: not added again (line 91 branch False)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"extended_early_bird_iso_weeks": [current_week],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
process_week_transition(tmp_path / "log.json", f)
state = json.loads(f.read_text())
assert state["extended_early_bird_iso_weeks"].count(current_week) == 1
class TestCurrentStreak:
"""Tests for current_streak."""
def test_returns_zero_when_file_missing(self, tmp_path: Path) -> None:
"""Missing file falls through _load_state → default 0."""
assert current_streak(tmp_path / "nonexistent.json") == 0
def test_returns_stored_streak(self, tmp_path: Path) -> None:
"""Stored streak value is returned correctly."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"consecutive_5plus_weeks": 5}))
assert current_streak(f) == 5
class TestHasSkipCredit:
"""Tests for has_skip_credit."""
def test_returns_false_when_no_credits(self, tmp_path: Path) -> None:
"""Zero credits → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
assert has_skip_credit(f) is False
def test_returns_true_when_credits_available(self, tmp_path: Path) -> None:
"""Non-zero credits → True."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 2}))
assert has_skip_credit(f) is True
class TestConsumeSkipCredit:
"""Tests for consume_skip_credit."""
def test_decrements_credit_count(self, tmp_path: Path) -> None:
"""Credits > 0: decrement by 1 (lines 129-133)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
consume_skip_credit(f)
assert json.loads(f.read_text())["skip_credits"] == 2
def test_does_nothing_when_no_credits(self, tmp_path: Path) -> None:
"""Credits == 0: no decrement (line 131 branch False)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
consume_skip_credit(f)
assert json.loads(f.read_text())["skip_credits"] == 0
class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird."""
def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None:
"""Current ISO week absent from list → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]}))
assert has_extended_early_bird(f) is False
def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None:
"""Current ISO week present in list → True."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]}))
assert has_extended_early_bird(f) is True