mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Sick days: per-locker keep (sickdays 0.2.0) for the workout lock
The workout lock, its explanation, the status view and the workday penalty's penalised day now ask _sick.stands_down_workout: a sick day that keeps the workout locker behaves as a normal day. The penalty's ring day is judged by the alarm switch. Shutdown (20:00 floor, grace floor, base, target, preview scripts) keeps is_sick: the floor belongs to the day, not to a locker. The one-tap button still keeps nothing. Test pool fixture and preview harness now redirect every sickdays resolve_paths binding, so keep edits never reach the real pool. Pins bumped to sickdays-v0.2.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
0b0a18e81c
commit
1a305c0627
@ -138,7 +138,7 @@ repos:
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
- id: pylint
|
||||
@ -160,7 +160,7 @@ repos:
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
|
||||
@ -57,7 +57,10 @@ constant, not the state file: the file once persisted `base_*_hour`, which
|
||||
made the code's default dead.
|
||||
|
||||
A sick day is a date in the shared `sickdays` pool, read only through
|
||||
`_sick.py`. The reset resolves it with `earned_time.resolve(..., sick=True)`
|
||||
`_sick.py`. It may keep lockers: the workout lock asks
|
||||
`_sick.stands_down_workout` (keep-aware), while everything that only computes
|
||||
shutdown (this floor, `_grace_floor`, `_shutdown_base`, `_shutdown_target`,
|
||||
the preview scripts) asks `is_sick` and ignores keep. The reset resolves it with `earned_time.resolve(..., sick=True)`
|
||||
(shutdown >= 20:00) and saves the lift above the earned sum like the grace
|
||||
lift; a day marked after the reset is lifted by `_sick_floor` on the next
|
||||
live pass. Both are floors, never terms: later credits are paid out of the
|
||||
|
||||
@ -19,6 +19,13 @@ and is paid out of that lift first, so it lands on the ladder value (e.g.
|
||||
20:40), never 20:00 plus its whole reward. `install.sh` migrates the old
|
||||
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
|
||||
|
||||
A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout
|
||||
lock behaves exactly as on a normal day (lock, quota, relaxed-day penalty on
|
||||
that date); only the 20:00 shutdown floor still applies, since it belongs to
|
||||
the day, not to a locker. The one-tap button keeps nothing. The missed-alarm
|
||||
penalty is void on its ring day only if `alarm` is off, and on its penalty day
|
||||
only if `workout` is off. All of it goes through `_sick.stands_down_workout`.
|
||||
|
||||
## Shutdown time
|
||||
|
||||
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_minutes()` in
|
||||
|
||||
@ -11,7 +11,7 @@ dependencies = [
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -21,7 +21,7 @@ pytest-xdist==3.8.0
|
||||
# module imports it itself, so it is declared rather than assumed.
|
||||
requests==2.34.2
|
||||
ruff==0.17.1
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays
|
||||
types-requests==2.33.0.20261006
|
||||
# Direct dependency since the Firebase session stream (_session_stream) catches
|
||||
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but
|
||||
|
||||
@ -25,7 +25,7 @@ import sys
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -83,8 +83,11 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
"""
|
||||
|
||||
def _is_sick_day_today(self) -> bool:
|
||||
"""Whether today is a date in the shared sickdays pool."""
|
||||
return is_sick()
|
||||
"""Whether the sickdays pool switches the workout lock off today.
|
||||
|
||||
A sick day that keeps the workout locker is a normal day here.
|
||||
"""
|
||||
return stands_down_workout()
|
||||
|
||||
def _check_early_exits(self) -> None:
|
||||
"""Check startup conditions and exit early when appropriate."""
|
||||
@ -121,7 +124,7 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
elif self._is_sick_day_today():
|
||||
_skip(
|
||||
"sick_day",
|
||||
"Today is a sick day (sickdays pool).",
|
||||
"Today is a sick day (sickdays pool); the workout lock is off.",
|
||||
**self._other_conditions("sick_day"),
|
||||
)
|
||||
elif self.has_logged_today():
|
||||
|
||||
@ -23,7 +23,7 @@ from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -128,9 +128,10 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo
|
||||
|
||||
|
||||
def is_sick_day_today(today: date | None = None) -> bool:
|
||||
"""Return True if ``today`` (default: today) is in the sickdays pool.
|
||||
"""Return True if the sickdays pool switches the workout lock off that day.
|
||||
|
||||
Kept here so callers of the status layer have one predicate module to
|
||||
import from; the answer itself is ``_sick.is_sick``'s.
|
||||
import from; the answer itself is ``_sick.stands_down_workout``'s, so a
|
||||
sick day that keeps the workout locker reads False.
|
||||
"""
|
||||
return is_sick(today)
|
||||
return stands_down_workout(today)
|
||||
|
||||
@ -137,8 +137,9 @@ def get_flags() -> dict[str, bool]:
|
||||
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
|
||||
leetcode-guard and home-guard are standing down today.
|
||||
|
||||
``is_sick_day_today`` reads the shared ``sickdays`` pool (the same fleet-wide
|
||||
answer every gate stands down on), never a screen-locker file.
|
||||
``is_sick_day_today`` reads the shared ``sickdays`` pool and answers "is the
|
||||
workout lock switched off today": a sick day that keeps the ``workout``
|
||||
locker reads False. Never a screen-locker file.
|
||||
"""
|
||||
return {
|
||||
"has_logged_today": has_logged_today(_LOG_FILE),
|
||||
|
||||
@ -7,6 +7,15 @@ them and tests have one seam.
|
||||
|
||||
Reading never raises: an unreadable pool is logged by ``sickdays`` and reads
|
||||
as *not sick* -- fail closed, the locks apply.
|
||||
|
||||
Two questions, deliberately not one: a sick day may *keep* some lockers active
|
||||
(``sickdays`` 0.2.0), so
|
||||
|
||||
* :func:`stands_down_workout` -- "is the workout lock off?" -- is sick AND the
|
||||
workout locker not kept. The lock ladder, its explanation, the status view
|
||||
and the missed-alarm penalty's penalised day ask this.
|
||||
* :func:`is_sick` -- "is the day sick at all?" -- feeds only the 20:00
|
||||
shutdown floor, which applies on every sick day whatever it keeps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -29,11 +38,34 @@ def is_sick(day: date | None = None) -> bool:
|
||||
return sickdays.is_sick_day(day)
|
||||
|
||||
|
||||
def stands_down(locker: str, day: date | None = None) -> bool:
|
||||
"""Whether ``locker`` (a ``sickdays.LOCKER_*``) is off on ``day``.
|
||||
|
||||
For the few consumers that judge a date by a locker other than the workout
|
||||
one (the missed-alarm penalty's ring day is the alarm switch's).
|
||||
"""
|
||||
return sickdays.stands_down(locker, day)
|
||||
|
||||
|
||||
def stands_down_workout(day: date | None = None) -> bool:
|
||||
"""Whether the workout lock is off on ``day`` (default: today).
|
||||
|
||||
True iff the day is sick and does not keep ``sickdays.LOCKER_WORKOUT``.
|
||||
A kept workout lock behaves exactly as on a normal day.
|
||||
"""
|
||||
return stands_down(sickdays.LOCKER_WORKOUT, day)
|
||||
|
||||
|
||||
def sick_dates() -> frozenset[date]:
|
||||
"""Every date the pool marks sick, past and future."""
|
||||
return sickdays.sick_days()
|
||||
|
||||
|
||||
def workout_standdown_dates() -> frozenset[date]:
|
||||
"""Every sick date on which the workout lock is off (keep-aware)."""
|
||||
return frozenset(d for d in sickdays.sick_days() if stands_down_workout(d))
|
||||
|
||||
|
||||
def push_in_background() -> threading.Thread:
|
||||
"""Start one best-effort ``sickdays.sync_quietly`` on a background thread.
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ from screen_locker._extra_benefits import (
|
||||
has_extended_early_bird,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick import sick_dates
|
||||
from screen_locker._sick import workout_standdown_dates
|
||||
from screen_locker._weekly_check import (
|
||||
COUNTED_WORKOUT_TYPES,
|
||||
WEEKLY_WORKOUT_MINIMUM,
|
||||
@ -77,7 +77,7 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
log_file: Path = locker.log_file
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = sick_dates()
|
||||
sick_days = workout_standdown_dates()
|
||||
|
||||
print("=== Weekly Workout Status ===")
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ from screen_locker._extra_benefits import (
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._sick import sick_dates
|
||||
from screen_locker._sick import workout_standdown_dates
|
||||
from screen_locker._weekly_check import (
|
||||
COUNTED_WORKOUT_TYPES,
|
||||
WEEKLY_WORKOUT_MINIMUM,
|
||||
@ -102,7 +102,7 @@ def _day_status(
|
||||
def _week_days(log_file: Path, *, today_local: datetime) -> tuple[DayStatus, ...]:
|
||||
"""Return this ISO week's days from Monday through today."""
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = sick_dates()
|
||||
sick_days = workout_standdown_dates()
|
||||
today_date = today_local.date()
|
||||
monday = today_date - timedelta(days=today_date.weekday())
|
||||
days: list[DayStatus] = []
|
||||
|
||||
@ -8,9 +8,13 @@ here is *HMAC ok, dated today*. Anything else -- missing file, stale date,
|
||||
bad signature -- is "no penalty", i.e. today's ordinary relaxed-day rule.
|
||||
|
||||
The file is void when EITHER its ``issued_date`` (the day of the missed
|
||||
ring) OR its ``penalty_date`` is a sick day (``sickdays``): a sick day stands
|
||||
every gate down, and wake-alarm's own retraction of the file can lag its
|
||||
sync by ~15 min, so it is not relied on here.
|
||||
ring) belongs to a sick day whose ``alarm`` switch is off, OR its
|
||||
``penalty_date`` belongs to a sick day whose ``workout`` lock is off
|
||||
(``sickdays``). Each date is judged by what it owns: the ring is the alarm's,
|
||||
and the penalty it costs -- the relaxed-day leniency -- is the workout lock's.
|
||||
A sick day that keeps that locker leaves its half of the penalty in force.
|
||||
Wake-alarm's own retraction of the file can lag its sync by ~15 min, so it
|
||||
is not relied on here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -21,10 +25,11 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
import sickdays
|
||||
|
||||
from screen_locker._constants import WORKDAY_PENALTY_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick import stands_down
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
@ -53,15 +58,20 @@ def workday_penalty_today(now: datetime | None = None) -> bool:
|
||||
entry = _read_verified()
|
||||
if entry is None or entry.get("penalty_date") != today_str(now):
|
||||
return False
|
||||
for field in ("issued_date", "penalty_date"):
|
||||
if _is_sick_iso(entry.get(field)):
|
||||
_logger.warning("Workday penalty void: its %s is a sick day", field)
|
||||
for field, locker in (
|
||||
("issued_date", sickdays.LOCKER_ALARM),
|
||||
("penalty_date", sickdays.LOCKER_WORKOUT),
|
||||
):
|
||||
if _stands_down_iso(entry.get(field), locker):
|
||||
_logger.warning(
|
||||
"Workday penalty void: its %s is a sick day with %s off", field, locker
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_sick_iso(value: object) -> bool:
|
||||
"""Whether ``value`` is a ``YYYY-MM-DD`` that the sickdays pool marks sick."""
|
||||
def _stands_down_iso(value: object, locker: str) -> bool:
|
||||
"""Whether ``value`` is a ``YYYY-MM-DD`` on which ``locker`` is switched off."""
|
||||
if value is None:
|
||||
return False # an older file without issued_date: penalty_date rules
|
||||
try:
|
||||
@ -69,4 +79,4 @@ def _is_sick_iso(value: object) -> bool:
|
||||
except ValueError:
|
||||
_logger.warning("Workday penalty: %r is not a date; not a sick day", value)
|
||||
return False
|
||||
return is_sick(day)
|
||||
return stands_down(locker, day)
|
||||
|
||||
@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
@ -62,10 +63,15 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
background push never reaches Firebase.
|
||||
"""
|
||||
import sickdays
|
||||
import sickdays._api
|
||||
|
||||
pool = sickdays.Paths.under(tmp_path / "sickdays")
|
||||
monkeypatch.setattr(sickdays._api, "resolve_paths", lambda paths: paths or pool)
|
||||
# Every sickdays module that bound ``resolve_paths`` by name: the readers
|
||||
# (``_api``, which ``stands_down`` / ``kept_lockers`` load through) and the
|
||||
# writers (``set_kept_lockers`` lives in ``_keep_api``). Patching only
|
||||
# ``_api`` once let a keep edit land in the developer's real pool.
|
||||
for name, module in list(sys.modules.items()):
|
||||
if name.startswith("sickdays.") and hasattr(module, "resolve_paths"):
|
||||
monkeypatch.setattr(module, "resolve_paths", lambda paths: paths or pool)
|
||||
monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False)
|
||||
|
||||
|
||||
|
||||
@ -8,17 +8,31 @@ that accepts any date, so a test can mark a fixed past day as well as today.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sickdays
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
def mark_sick(day: date | str | None = None) -> date:
|
||||
"""Mark ``day`` (default: today, local) sick and return it."""
|
||||
|
||||
def mark_sick(day: date | str | None = None, *, keep: Iterable[str] = ()) -> date:
|
||||
"""Mark ``day`` (default: today, local) sick and return it.
|
||||
|
||||
``keep`` names the lockers (``sickdays.LOCKER_*``) the day keeps active;
|
||||
the default keeps none, i.e. every locker stands down, like the lock
|
||||
screen's one-tap button.
|
||||
"""
|
||||
if day is None:
|
||||
target = sickdays.today()
|
||||
elif isinstance(day, str):
|
||||
target = date.fromisoformat(day)
|
||||
else:
|
||||
target = day
|
||||
sickdays.import_legacy([target])
|
||||
if keep:
|
||||
# ``now=target`` makes a past day "today", so a kept past day can be
|
||||
# written; import_legacy cannot carry a keep set.
|
||||
sickdays.add_sick_day(target, keep=keep, is_free=lambda _day: False, now=target)
|
||||
else:
|
||||
sickdays.import_legacy([target])
|
||||
return target
|
||||
|
||||
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
@ -100,6 +101,21 @@ class TestExplainLockDecision:
|
||||
assert result.fired is False
|
||||
assert result.stage == "sick_day"
|
||||
|
||||
def test_a_sick_day_that_keeps_the_workout_lock_does_not_skip(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
files = self._files(tmp_path)
|
||||
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
|
||||
)
|
||||
assert result.stage == "would_lock"
|
||||
sick_step = next(t for t in result.trace if t.name == "sick_day")
|
||||
assert sick_step.fired is False
|
||||
|
||||
def test_already_logged_skips(self, tmp_path: Path) -> None:
|
||||
files = self._files(tmp_path)
|
||||
files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}}))
|
||||
|
||||
@ -11,6 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _earned, _grace_floor
|
||||
from screen_locker._grace_floor import (
|
||||
@ -193,6 +194,13 @@ class TestApply:
|
||||
assert "failed to write" in caplog.text
|
||||
assert plan_absorb(0) == (0, 0)
|
||||
|
||||
def test_a_sick_day_that_keeps_the_workout_lock_still_has_no_floor(self) -> None:
|
||||
"""The sick floor is shutdown's, so keep does not bring the grace floor back."""
|
||||
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
|
||||
host = Host((18 * 60, 18 * 60, 300))
|
||||
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
|
||||
assert host.config == (18 * 60, 18 * 60, 300)
|
||||
|
||||
def test_a_sick_day_has_no_floor(self) -> None:
|
||||
"""A sick day has the 20:00 sick floor instead."""
|
||||
mark_sick(_DAY)
|
||||
|
||||
@ -8,15 +8,19 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _grace_floor
|
||||
from screen_locker._shutdown_base import base_minutes
|
||||
from screen_locker._shutdown_target import (
|
||||
DayInputs,
|
||||
day_credit_count,
|
||||
derive,
|
||||
gather,
|
||||
gather_inputs,
|
||||
workout_units,
|
||||
)
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -67,6 +71,19 @@ class TestTarget:
|
||||
assert target.minutes > SICK_FLOOR
|
||||
assert target.lift == 0
|
||||
|
||||
def test_gather_keeps_the_sick_floor_when_the_workout_lock_is_kept(self) -> None:
|
||||
"""Rule 2: the 20:00 floor is the day's, whatever the keep set says."""
|
||||
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
|
||||
assert gather_inputs(_DAY, None).sick_day is True
|
||||
|
||||
def test_base_minutes_keeps_the_sick_floor_when_the_workout_lock_is_kept(
|
||||
self,
|
||||
) -> None:
|
||||
plain = base_minutes(_DAY)
|
||||
assert plain < SICK_FLOOR # reading's cut is in force on this date
|
||||
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
|
||||
assert base_minutes(_DAY) == SICK_FLOOR
|
||||
|
||||
def test_not_sick_is_the_plain_sum(self) -> None:
|
||||
target = derive(DayInputs(_DAY, _FLAT))
|
||||
assert target.resolution.sick is False
|
||||
|
||||
@ -11,6 +11,8 @@ import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _sick, _ui_flows_sick
|
||||
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
||||
from screen_locker._compliance_predicates import is_sick_day_today
|
||||
from screen_locker.tests._locker_factories import create_locker_relaxed_day
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
@ -36,11 +38,59 @@ class TestSeam:
|
||||
assert _sick.is_sick(day - timedelta(days=1)) is False
|
||||
assert _sick.sick_dates() >= {day, old}
|
||||
|
||||
def test_stands_down_workout_follows_the_keep_set(self) -> None:
|
||||
day = mark_sick(keep=[sickdays.LOCKER_WORKOUT])
|
||||
other = mark_sick(day + timedelta(days=1), keep=[sickdays.LOCKER_LEETCODE])
|
||||
assert _sick.stands_down_workout(day) is False
|
||||
assert _sick.stands_down_workout(other) is True
|
||||
assert _sick.stands_down_workout(other + timedelta(days=1)) is False
|
||||
# The shutdown floor ignores keep: the day is sick either way.
|
||||
assert _sick.is_sick(day) is True
|
||||
|
||||
def test_stands_down_workout_defaults_to_today(self) -> None:
|
||||
assert _sick.stands_down_workout() is False
|
||||
mark_sick()
|
||||
assert _sick.stands_down_workout() is True
|
||||
|
||||
def test_workout_standdown_dates_drop_the_kept_days(self) -> None:
|
||||
kept = mark_sick("2026-06-21", keep=[sickdays.LOCKER_WORKOUT])
|
||||
off = mark_sick("2026-07-24")
|
||||
assert _sick.workout_standdown_dates() >= {off}
|
||||
assert kept not in _sick.workout_standdown_dates()
|
||||
assert _sick.sick_dates() >= {kept, off}
|
||||
|
||||
def test_the_startup_ladder_asks_the_workout_switch(self) -> None:
|
||||
"""``_is_sick_day_today`` is what the lock skip reads: keep-aware."""
|
||||
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
|
||||
assert AutoUpgradeMixin._is_sick_day_today(MagicMock()) is False
|
||||
sickdays.set_kept_lockers(sickdays.today(), [sickdays.LOCKER_BOOK])
|
||||
assert AutoUpgradeMixin._is_sick_day_today(MagicMock()) is True
|
||||
|
||||
def test_the_pool_fixture_covers_keep_edits(self) -> None:
|
||||
"""``set_kept_lockers`` must land in the tmp pool, never the real one."""
|
||||
day = mark_sick()
|
||||
sickdays.set_kept_lockers(day, [sickdays.LOCKER_DIET])
|
||||
assert sickdays.kept_lockers(day) == {sickdays.LOCKER_DIET}
|
||||
|
||||
def test_the_status_predicate_follows_the_workout_switch(self) -> None:
|
||||
mark_sick(keep=[sickdays.LOCKER_WORKOUT])
|
||||
assert is_sick_day_today() is False
|
||||
sickdays.set_kept_lockers(sickdays.today(), [sickdays.LOCKER_LEETCODE])
|
||||
assert is_sick_day_today() is True
|
||||
|
||||
def test_mark_today_sick(self) -> None:
|
||||
result = _sick.mark_today_sick()
|
||||
assert result.ok is True
|
||||
assert sickdays.is_sick_day() is True
|
||||
|
||||
def test_the_one_tap_button_keeps_nothing(self) -> None:
|
||||
"""The lock screen's "I'm sick" is ALL-OFF: every locker stands down."""
|
||||
assert _sick.mark_today_sick().ok is True
|
||||
assert sickdays.kept_lockers() == frozenset()
|
||||
for locker in sickdays.LOCKERS:
|
||||
assert sickdays.stands_down(locker) is True
|
||||
assert _sick.stands_down_workout() is True
|
||||
|
||||
def test_already_sick_is_success(self) -> None:
|
||||
mark_sick()
|
||||
result = _sick.mark_today_sick()
|
||||
|
||||
@ -14,6 +14,7 @@ from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _grace_floor, _shutdown, _sick_floor
|
||||
from screen_locker._earned import first_minutes
|
||||
@ -65,6 +66,13 @@ class TestApplySickFloor:
|
||||
assert host._apply_sick_floor() is False
|
||||
assert host.config == (18 * 60, 18 * 60, 300)
|
||||
|
||||
def test_a_kept_workout_lock_does_not_lift_the_floor_away(self) -> None:
|
||||
"""The 20:00 floor is the day's, not the workout lock's: keep is moot."""
|
||||
mark_sick(_DAY, keep=[sickdays.LOCKER_WORKOUT])
|
||||
host = Host((18 * 60, 18 * 60 + 30, 300))
|
||||
assert host._apply_sick_floor() is True
|
||||
assert host.config == (_FLOOR, _FLOOR, 300)
|
||||
|
||||
def test_marked_sick_lifts_to_20_00_and_records_the_lift(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host = Host((18 * 60, 18 * 60 + 30, 300))
|
||||
|
||||
@ -13,6 +13,7 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker._status_data import format_summary_line, gather_status
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
@ -46,6 +47,21 @@ class TestGatherStatusLockSuppression:
|
||||
assert snap.lock_explanation.fired is False
|
||||
assert snap.lock_explanation.stage == "sick_day"
|
||||
|
||||
def test_sick_day_keeping_the_workout_lock_still_locks(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A kept workout lock is a normal day: not marked sick, not skipped."""
|
||||
files = _files(tmp_path)
|
||||
mark_sick("2024-01-05", keep=[sickdays.LOCKER_WORKOUT])
|
||||
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 False
|
||||
assert not format_summary_line(snap).endswith("· sick day")
|
||||
assert snap.lock_explanation.stage != "sick_day"
|
||||
|
||||
def test_wake_alarm_skip_stops_lock(self, tmp_path: Path) -> None:
|
||||
"""Wake alarm skip stops lock."""
|
||||
files = _files(tmp_path)
|
||||
|
||||
@ -9,6 +9,7 @@ from unittest.mock import patch
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _workday_penalty
|
||||
from screen_locker._workday_penalty import workday_penalty_today
|
||||
@ -87,3 +88,27 @@ class TestSickDayVoidsThePenalty:
|
||||
_write({"penalty_date": "2026-09-23", "issued_date": "yesterday"})
|
||||
assert workday_penalty_today(NOW) is True
|
||||
assert "not a date" in caplog.text
|
||||
|
||||
|
||||
class TestKeptLockersDecideThePenalty:
|
||||
"""Ring date -> the alarm switch; penalty date -> the workout lock."""
|
||||
|
||||
def test_a_kept_alarm_on_the_ring_day_keeps_the_penalty(self) -> None:
|
||||
mark_sick("2026-09-22", keep=[sickdays.LOCKER_ALARM])
|
||||
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
|
||||
assert workday_penalty_today(NOW) is True
|
||||
|
||||
def test_a_kept_workout_on_the_ring_day_still_voids_it(self) -> None:
|
||||
mark_sick("2026-09-22", keep=[sickdays.LOCKER_WORKOUT])
|
||||
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
|
||||
assert workday_penalty_today(NOW) is False
|
||||
|
||||
def test_a_kept_workout_on_the_penalty_day_keeps_it(self) -> None:
|
||||
mark_sick("2026-09-23", keep=[sickdays.LOCKER_WORKOUT])
|
||||
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
|
||||
assert workday_penalty_today(NOW) is True
|
||||
|
||||
def test_a_kept_alarm_on_the_penalty_day_still_voids_it(self) -> None:
|
||||
mark_sick("2026-09-23", keep=[sickdays.LOCKER_ALARM])
|
||||
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
|
||||
assert workday_penalty_today(NOW) is False
|
||||
|
||||
@ -8,7 +8,7 @@ adb, the sudo-written config, the sickdays pool) point at a temporary place.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
import importlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
@ -114,8 +114,11 @@ SICK_STEP = "sick"
|
||||
def use_temp_pool(tmp: Path) -> None:
|
||||
"""Point every sickdays read and write at a pool under ``tmp``."""
|
||||
pool = sickdays.Paths.under(tmp / "sickdays")
|
||||
api = importlib.import_module("sickdays._api")
|
||||
vars(api)["resolve_paths"] = lambda paths: paths or pool
|
||||
# Every sickdays module that bound ``resolve_paths`` by name: ``_api`` (the
|
||||
# reads, ``stands_down`` included) and the writers (``_keep_api``, ...).
|
||||
for name, module in tuple(sys.modules.items()):
|
||||
if name.startswith("sickdays.") and "resolve_paths" in vars(module):
|
||||
vars(module)["resolve_paths"] = lambda paths: paths or pool
|
||||
|
||||
|
||||
def sick_pool(day: date, *, sick_day: bool) -> bool:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user