screen-locker/screen_locker/tests/test_weekly_check.py
Krzysztof kuhy Rudnicki 298daf028d Give manual workouts full effect parity; raise minimum to 5, double budget
Synced manual workouts never earned shutdown/debt credit (only weekly
count), which silently missed pushing shutdown time for a same-day
workout logged on the phone. Manual workouts now go through the same
credit logic as any other workout type, count individually toward the
weekly total and rate budget (no more once-per-day collapse), and stack
same-day shutdown bonuses like verified workouts. Also bumps the weekly
minimum from 4 to 5 and doubles the monthly manual-workout budget to 10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 13:50:39 +02:00

260 lines
9.2 KiB
Python

"""Tests for _weekly_check: is_relaxed_day, count_weekly_workouts,
has_weekly_minimum."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from screen_locker._weekly_check import (
_RELAXED_WEEKDAYS,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
has_weekly_minimum,
is_relaxed_day,
)
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dt(weekday: int, hour: int = 10) -> datetime:
"""Return a UTC-aware datetime for the given ISO weekday (0=Mon, 6=Sun)."""
# 2025-05-19 is a Monday (weekday 0)
base = datetime(2025, 5, 19, hour, 0, 0, tzinfo=timezone.utc)
from datetime import timedelta
return base + timedelta(days=weekday)
def _make_log(entries: dict[str, str], log_file: Path) -> Path:
"""Write a workout_log.json with given date→workout_type mapping."""
data: dict[str, Any] = {
date: {
"timestamp": f"{date}T10:00:00+00:00",
"workout_data": {"type": wtype},
}
for date, wtype in entries.items()
}
log_file.write_text(json.dumps(data))
return log_file
# ---------------------------------------------------------------------------
# is_relaxed_day
# ---------------------------------------------------------------------------
class TestIsRelaxedDay:
def test_monday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(0)) is False
def test_tuesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(1)) is True
def test_wednesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(2)) is True
def test_thursday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(3)) is True
def test_friday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(4)) is False
def test_saturday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(5)) is False
def test_sunday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(6)) is False
def test_relaxed_weekdays_constant_correct(self) -> None:
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
def test_uses_local_time_by_default(self) -> None:
result = is_relaxed_day()
assert isinstance(result, bool)
# ---------------------------------------------------------------------------
# count_weekly_workouts
# ---------------------------------------------------------------------------
class TestCountWeeklyWorkouts:
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{not valid json}")
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_oserror_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{}")
with patch("builtins.open", side_effect=OSError("no permission")):
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23
_make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_sick_day_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "sick_day"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_early_bird_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "early_bird"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_previous_week_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-12 is the Monday of the previous week
_make_log({"2025-05-12": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_future_date_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-24 is Saturday, checking on Friday 2025-05-23
_make_log({"2025-05-24": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_invalid_date_key_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {
"not-a-date": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
"2025-05-19": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_non_dict_entry_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {"2025-05-19": "not-a-dict"}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_up_to_four(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 4
def test_today_counts_if_this_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# today is Friday 2025-05-23
_make_log({"2025-05-23": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_monday_start_of_week_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "phone_verified"}, log)
# Checking on Monday itself (today=Mon)
assert count_weekly_workouts(log, today=_dt(0)) == 1
def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "sick_day",
"2025-05-21": "early_bird",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_multiple_same_day_manual_workouts_each_count(self, tmp_path: Path) -> None:
"""Manual workouts count individually now, same as verified — no
once-per-day collapse."""
log = tmp_path / "workout_log.json"
log.write_text(
json.dumps(
{
"2025-05-19": [
{"workout_data": {"type": "manual_workout"}},
{"workout_data": {"type": "manual_workout"}},
],
}
)
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
# ---------------------------------------------------------------------------
# has_weekly_minimum
# ---------------------------------------------------------------------------
class TestHasWeeklyMinimum:
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
"2025-05-23": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
assert WEEKLY_WORKOUT_MINIMUM == 5