mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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>
260 lines
9.2 KiB
Python
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
|