screen-locker/screen_locker/tests/test_weekly_check.py
Krzysztof kuhy Rudnicki ec0946f64e Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That last one makes the source
3.14-only at the syntax level, which is what `requires-python = ">=3.14"`
already promised; kuhy chose it deliberately over holding ruff a release back
(2026-08-29).

The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.

ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:42:47 +02:00

237 lines
8.9 KiB
Python

"""Tests for _weekly_check: is_relaxed_day, count_weekly_workouts,
has_weekly_minimum."""
from __future__ import annotations
from datetime import UTC, datetime
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from screen_locker._weekly_check import (
_RELAXED_WEEKDAYS,
count_weekly_workouts,
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=UTC)
from datetime import timedelta
return base + timedelta(days=weekday)
def _make_log(entries: dict[str, str], log_file: Path) -> Path:
"""Write a 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:
"""Tue-Thu are optional; Fri-Mon are enforced lock days."""
def test_monday_is_enforced(self) -> None:
"""Monday is an enforced day."""
assert is_relaxed_day(today=_dt(0)) is False
def test_tuesday_is_relaxed(self) -> None:
"""Tuesday is relaxed."""
assert is_relaxed_day(today=_dt(1)) is True
def test_wednesday_is_relaxed(self) -> None:
"""Wednesday is relaxed."""
assert is_relaxed_day(today=_dt(2)) is True
def test_thursday_is_relaxed(self) -> None:
"""Thursday is relaxed."""
assert is_relaxed_day(today=_dt(3)) is True
def test_friday_is_enforced(self) -> None:
"""Friday is an enforced day."""
assert is_relaxed_day(today=_dt(4)) is False
def test_saturday_is_enforced(self) -> None:
"""Saturday is an enforced day."""
assert is_relaxed_day(today=_dt(5)) is False
def test_sunday_is_enforced(self) -> None:
"""Sunday is an enforced day."""
assert is_relaxed_day(today=_dt(6)) is False
def test_relaxed_weekdays_constant_correct(self) -> None:
"""The relaxed set is exactly Tue/Wed/Thu (weekday 1-3)."""
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
def test_uses_local_time_by_default(self) -> None:
"""Called with no argument it reads local time and still returns a bool."""
result = is_relaxed_day()
assert isinstance(result, bool)
# ---------------------------------------------------------------------------
# count_weekly_workouts
# ---------------------------------------------------------------------------
class TestCountWeeklyWorkouts:
"""Counting verified workouts inside the current ISO week."""
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
"""A missing log counts as zero rather than raising."""
log = tmp_path / "log.json"
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
"""Unparsable JSON counts as zero rather than raising."""
log = tmp_path / "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:
"""An unreadable log counts as zero rather than raising."""
log = tmp_path / "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:
"""Two phone-verified days in the same week count as two."""
log = tmp_path / "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:
"""A sick day is not a workout."""
log = tmp_path / "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:
"""An early-bird entry is not a workout."""
log = tmp_path / "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:
"""Last week's workouts do not count toward this week."""
log = tmp_path / "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:
"""A date after today does not count, even in the same week."""
log = tmp_path / "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:
"""An unparsable date key is skipped, and valid siblings still count."""
log = tmp_path / "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:
"""An entry that is not a dict is skipped rather than raising."""
log = tmp_path / "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:
"""Four workouts in one week count as four (no cap below the minimum)."""
log = tmp_path / "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:
"""Today's own workout counts."""
log = tmp_path / "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:
"""Monday is the first day of the week, so it counts when checked on Monday."""
log = tmp_path / "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:
"""Only verified workouts count; sick/early-bird entries are ignored."""
log = tmp_path / "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 / "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
# ---------------------------------------------------------------------------