mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
A manual workout may be dated today..today+7 days (no past dates). A future entry is held out of log.json and earns nothing until its date; the first sync/lock/reset pass on that day ingests and credits it like a workout logged that day. - ingest_manual_records defers records dated after the local day and counts the budget (2/7d, 10/30d) against the workout's own date over logged credits plus pending future records (a pending record never rejects itself) - PC-logged future entries (Tk dialog, --log-manual-workout --date) go to a durable pending ledger re-published on every push (a one-shot publish to devices/pc is overwritten by the next push); tombstones win, entries leave the ledger once logged or deleted - one shared date helper (validate_workout_date) used by the dialog and CLI; the form's date field rides in the sport row to keep the lock screen fitting - poke start_the_day also ingests due local entries after the daily reset - PC admission rule pinned to the phone's by the shared budget fixture (planned.expected_by_date) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
200 lines
7.5 KiB
Python
200 lines
7.5 KiB
Python
"""Tests for the future-dated manual-workout date rule and per-date budget."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from screen_locker import _manual_future
|
|
from screen_locker._log_mixin import write_signed_entry
|
|
from screen_locker._manual_future import (
|
|
BudgetUsage,
|
|
budget_closed_for_all_days,
|
|
budget_error_for_date,
|
|
budget_usage_for_date,
|
|
is_future_date,
|
|
parse_workout_date,
|
|
validate_workout_date,
|
|
)
|
|
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_TODAY = "2026-10-16"
|
|
|
|
|
|
def _plus(days: int, base: str = _TODAY) -> str:
|
|
"""Return the ISO date ``days`` after ``base``."""
|
|
return (date.fromisoformat(base) + timedelta(days=days)).isoformat()
|
|
|
|
|
|
def _log(tmp_path: Path, *days: str) -> Path:
|
|
"""Write one manual workout per day into a real signed log."""
|
|
log_file = tmp_path / "log.json"
|
|
for day in days:
|
|
data = {
|
|
"type": MANUAL_WORKOUT_TYPE,
|
|
"start_time": "18:00",
|
|
"workout_id": f"manual:{day}T18:00",
|
|
}
|
|
write_signed_entry(log_file, day, data)
|
|
return log_file
|
|
|
|
|
|
class TestParseWorkoutDate:
|
|
"""Strict YYYY-MM-DD parsing."""
|
|
|
|
def test_parses_an_iso_date(self) -> None:
|
|
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:
|
|
with caplog.at_level(logging.WARNING):
|
|
assert parse_workout_date(bad) is None
|
|
assert "not YYYY-MM-DD" in caplog.text
|
|
|
|
|
|
class TestIsFutureDate:
|
|
"""Strictly after the reference day, valid dates only."""
|
|
|
|
def test_tomorrow_is_future_and_today_is_not(self) -> None:
|
|
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:
|
|
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:
|
|
assert is_future_date("2999-01-01") is True
|
|
|
|
|
|
class TestValidateWorkoutDate:
|
|
"""The one 0..7 days-ahead rule shared by the form and the CLI."""
|
|
|
|
@pytest.mark.parametrize("ahead", [0, 1, 7])
|
|
def test_accepts_today_through_plus_seven(self, ahead: int) -> None:
|
|
assert validate_workout_date(_plus(ahead), today=_TODAY) is None
|
|
|
|
def test_rejects_the_past(self) -> None:
|
|
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:
|
|
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:
|
|
assert validate_workout_date("soon", today=_TODAY) == (
|
|
"Workout date must be YYYY-MM-DD"
|
|
)
|
|
|
|
def test_reports_an_unusable_reference_day(self) -> None:
|
|
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:
|
|
assert validate_workout_date("1999-01-01") is not None
|
|
|
|
|
|
class TestBudgetUsage:
|
|
"""The fullest windows that contain the workout date."""
|
|
|
|
def test_counts_logged_and_pending_in_the_fullest_window(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
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)
|
|
assert usage is not None
|
|
assert usage.exhausted is True
|
|
|
|
def test_an_empty_log_is_not_exhausted(self, tmp_path: Path) -> None:
|
|
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:
|
|
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:
|
|
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:
|
|
assert budget_usage_for_date(tmp_path / "none.json", "x") is None
|
|
|
|
|
|
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:
|
|
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:
|
|
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 = [_plus(1), _plus(2)]
|
|
error = budget_error_for_date(tmp_path / "none.json", _plus(3), pending)
|
|
assert error is not None
|
|
assert budget_error_for_date(tmp_path / "none.json", _plus(9), pending) is None
|
|
|
|
def test_the_monthly_cap_applies(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> 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.
|
|
monkeypatch.setattr(_manual_future, "_WINDOWS", ((30, 2),))
|
|
days = [_plus(-10), _plus(10)]
|
|
error = budget_error_for_date(tmp_path / "none.json", _TODAY, days)
|
|
assert error is not None
|
|
assert "per 30 days" in error
|
|
|
|
def test_a_bad_date_is_refused(self, tmp_path: Path) -> None:
|
|
error = budget_error_for_date(tmp_path / "none.json", "later")
|
|
assert error is not None
|
|
assert "YYYY-MM-DD" in error
|
|
|
|
|
|
class TestBudgetClosedForAllDays:
|
|
"""The button hides only when no day today..+7 has room."""
|
|
|
|
def test_open_when_empty(self, tmp_path: Path) -> None:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
assert not budget_closed_for_all_days(tmp_path / "n.json")
|