screen-locker/screen_locker/tests/test_status_data_part3.py
Krzysztof kuhy Rudnicki d6e8c0973f Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.

Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.

This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.

scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.

add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.

conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.

requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.

1256 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:41 +02:00

165 lines
6.8 KiB
Python

"""Tests for gather_status's lock-suppression paths.
Split out of test_status_data_part2.py for the 250-line cap: these are the
cases where something (sick day, wake alarm, scheduled skip, relaxed day,
weekly minimum) means no lock is needed.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import freedays
from screen_locker._status_data import gather_status
from screen_locker.tests.test_status_data import _files
if TYPE_CHECKING:
from pathlib import Path
# Fixed reference instant: Friday 2024-01-05, 12:00 UTC == 13:00 Europe/Warsaw.
# Outside the 05:00-09:00 early-bird window and not a Tue/Wed/Thu relaxed day,
# so lock-decision branches are fully deterministic regardless of wall clock.
_FRIDAY_NOON_UTC = datetime(2024, 1, 5, 12, 0, tzinfo=UTC)
# Monday of that same ISO week, for Mon-Wed shutdown-band assertions.
_MONDAY_NOON_UTC = datetime(2024, 1, 1, 12, 0, tzinfo=UTC)
class TestGatherStatusLockSuppression:
"""Conditions under which gather_status reports no lock is needed."""
def test_sick_day_marks_today_and_skips_lock(self, tmp_path: Path) -> None:
"""Sick day marks today and skips lock."""
files = _files(tmp_path)
(tmp_path / "sick_history.json").write_text(
json.dumps({"sick_days": ["2024-01-05"], "debt": 1})
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.today.is_sick_day is True
assert snap.sick_budget.used_7d == 1
assert snap.sick_budget.debt == 1
assert snap.lock_explanation.fired is False
assert snap.lock_explanation.stage == "sick_day"
def test_sick_budget_exhausted_flag(self, tmp_path: Path) -> None:
"""Sick budget exhausted flag."""
files = _files(tmp_path)
(tmp_path / "sick_history.json").write_text(
json.dumps({"sick_days": ["2024-01-05"], "debt": 0})
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.sick_budget.exhausted is True
def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None:
"""Wake alarm skip stops lock."""
files = _files(tmp_path)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=True
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.lock_explanation.fired is False
assert snap.lock_explanation.stage == "wake_alarm_skip"
def test_scheduled_skip_stops_lock(self, tmp_path: Path) -> None:
"""Scheduled skip stops lock."""
files = _files(tmp_path)
freedays.mark(
date(2024, 1, 5),
paths=freedays.Paths.under(tmp_path / "freedays"),
now=date(2024, 1, 5),
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.lock_explanation.stage == "scheduled_skip"
def test_relaxed_day_stops_lock(self, tmp_path: Path) -> None:
"""Tuesday is a relaxed day — is_relaxed_day derives it from `now`."""
files = _files(tmp_path)
tuesday_noon = datetime(2024, 1, 2, 12, 0, tzinfo=UTC)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=tuesday_noon)
assert snap.lock_explanation.stage == "relaxed_day"
def test_weekly_minimum_met_stops_lock(self, tmp_path: Path) -> None:
# Avoid dating anything "today" (2024-01-05): an unsigned today entry
# trips already_logged before weekly_minimum_met, and does so only
# when no HMAC key is configured (env-dependent) -- Jan 4 holds two.
"""Weekly minimum met stops lock."""
files = _files(tmp_path)
files["log_file"].write_text(
json.dumps(
{
"2024-01-01": {"workout_data": {"type": "phone_verified"}},
"2024-01-02": {"workout_data": {"type": "phone_verified"}},
"2024-01-03": {"workout_data": {"type": "phone_verified"}},
# A gym session and a run on the same day: two genuinely
# separate workouts, so two credits. Deliberately NOT two
# phone_verified entries -- since the 2026-08-29 fix those
# share one StrongLifts credit slot per day, which is the
# whole point of the dedup.
"2024-01-04": [
{"workout_data": {"type": "phone_verified"}},
{"workout_data": {"type": "runnerup_verified"}},
],
}
)
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.week.counted_count == 5
assert snap.week.remaining == 0
assert snap.week.extra == 0
assert snap.lock_explanation.stage == "weekly_minimum_met"
def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None:
"""Extra workouts beyond minimum."""
files = _files(tmp_path)
# Jan 1-4 one each, Jan 5 (today, Friday — the last day in-week without
# going into the future) holds two, for 6 counted total: 1 above the
# new minimum of 5. Also exercises multiple same-day entries each
# counting individually — which is why the pair is a gym session plus a
# run rather than two phone_verified entries: those are the two
# ingestion paths for ONE StrongLifts session and share a credit slot.
files["log_file"].write_text(
json.dumps(
{
"2024-01-01": {"workout_data": {"type": "phone_verified"}},
"2024-01-02": {"workout_data": {"type": "phone_verified"}},
"2024-01-03": {"workout_data": {"type": "phone_verified"}},
"2024-01-04": {"workout_data": {"type": "phone_verified"}},
"2024-01-05": [
{"workout_data": {"type": "phone_verified"}},
{"workout_data": {"type": "runnerup_verified"}},
],
}
)
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.week.extra == 1
assert snap.week.remaining == 0