mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 16:48:53 +01:00
late credits: a credit at/after tonight's shutdown sets it to min(23:00, its own time + reward); grace floor removed (also on sick days)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f2ddf3a491
commit
46393fa866
25
AGENTS.md
25
AGENTS.md
@ -59,12 +59,25 @@ made the code's default dead.
|
||||
A sick day is a date in the shared `sickdays` pool, read only through
|
||||
`_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
|
||||
lift first (`_grace_floor.plan_absorb`), so nothing stacks on 20:00.
|
||||
shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview
|
||||
scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute:
|
||||
20:00 plus the GAMING pay of everything credited that day, capped at 23:00
|
||||
(`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor`
|
||||
re-derives it on every live pass from the ledgers, and a sick-day credit only
|
||||
raises to it (`_raise_on_sick_day`), never adds -- its row is already in the
|
||||
sum. No lift is saved on a sick day.
|
||||
|
||||
## A shutdown never blocks submitting a finished task
|
||||
|
||||
A credit timed at or after tonight's shutdown moves tonight to
|
||||
`min(23:00, the credit's OWN time + its reward)` (`_late_credit.late_tonight`):
|
||||
only the 23:00 ceiling is hard. The anchor is the credit's own time (the run's
|
||||
end via `_workout_ledger.entry_done_at`, the ledger row via
|
||||
`_earned.first_credit_time`), never the sync time, so a late upload buys
|
||||
nothing. It is live-only: the next day's reset re-derives from scratch. This
|
||||
replaced the first-credit +60 grace floor (gone 2026-10-17); `_grace_floor`
|
||||
keeps only `first_done_at`, `tonight_minutes` and the lift file, which
|
||||
`plan_absorb` spends once if a stale one is left.
|
||||
|
||||
Flat earners are read from their gate's ledger by `earned_time.done_today`
|
||||
via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own
|
||||
|
||||
16
README.md
16
README.md
@ -10,13 +10,15 @@ A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/uti
|
||||
pool, nothing else: no budget, countdown, justification or debt. The lock
|
||||
screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu
|
||||
prompt) marks today with one tap and unlocks; `sickdays add` or the phone do
|
||||
the same. On a sick day the workout lock and the grace floor stand down,
|
||||
wake-alarm's workday penalty is void, and shutdown is
|
||||
`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(...,
|
||||
sick=True)`). A day marked after its daily reset is lifted to 20:00 by the
|
||||
next 5-minute pass (`_sick_floor`); a workout that day still earns normally
|
||||
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
|
||||
the same. On a sick day the workout lock stands down,
|
||||
wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming
|
||||
pay of everything credited that day, capped at 23:00
|
||||
(`earned_time.sick_shutdown_from`). A day marked after its daily reset is
|
||||
raised to it by the next live pass (`_sick_floor`), and every later credit
|
||||
that day raises it again.
|
||||
|
||||
A credit landing at or after tonight's shutdown is never lost: tonight
|
||||
becomes `min(23:00, the credit's own time + its reward)` (`_late_credit`). `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
|
||||
|
||||
@ -105,7 +105,6 @@ SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
||||
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
|
||||
REST_DAYS_PER_ISO_WEEK = 2
|
||||
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
|
||||
GRACE_MINUTES = 60
|
||||
# A day's RunnerUp walks (commute legs) count as the workout once their
|
||||
# MOVING time sums to this; moving = trackpoint pairs at walking speed.
|
||||
WALK_MIN_MOVING_MINUTES = 40
|
||||
|
||||
@ -173,7 +173,7 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
|
||||
``None`` when there is none, when it cannot be checked, and on an
|
||||
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because
|
||||
then the grace floor sees no ledger earner at all.
|
||||
then a late ledger credit counts from now, not from its row.
|
||||
"""
|
||||
fn = getattr(earned_time, "first_credit_at", None)
|
||||
if fn is None:
|
||||
@ -187,8 +187,8 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
def _warn_no_first_credit() -> None:
|
||||
"""Once per process: the installed earned_time predates first_credit_at."""
|
||||
_logger.warning(
|
||||
"earned_time has no first_credit_at (needs >= 0.4); the grace floor "
|
||||
"sees no ledger earner, only the workout log"
|
||||
"earned_time has no first_credit_at (needs >= 0.4); a late ledger "
|
||||
"credit counts from now, not from its own time"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,22 +1,15 @@
|
||||
"""Grace floor: shutdown is never earlier than the day's first task + 60 min.
|
||||
"""When the day's first task was done, and the day's floor lift.
|
||||
|
||||
Someone who gets home at 18:10 and finishes their first task at 18:55 is not
|
||||
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the
|
||||
day's ceiling. "First done" is the earliest of every gate earner's first
|
||||
verified credit, flat or counted (``earned_time.first_credit_at``), and the
|
||||
workout's own completion time -- the end of the run or walk, never the
|
||||
moment a sync happened to log it, so a late upload does not move the floor
|
||||
later.
|
||||
The grace floor (first task + 60 min) is gone since 2026-10-17: a credit at
|
||||
or after tonight's shutdown now counts from its own time
|
||||
(:mod:`screen_locker._late_credit`), which is what the floor approximated,
|
||||
and the floor's lift then absorbed minutes the late credit had already
|
||||
restarted from (LeetCode late at 19:00, reading at 19:30: 20:10, not 20:20).
|
||||
|
||||
**A floor, not a bonus.** The config is otherwise read-add-write: every
|
||||
earner adds its minutes to whatever is there. Lifting it to the floor and
|
||||
then letting the next earner add on top would pay the lift twice (anki at
|
||||
21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is
|
||||
22:00). So the lift is remembered for the day in ``grace_floor.json`` and
|
||||
later additions are paid out of it first: with ``E`` earned, floor ``G`` and
|
||||
lift ``L = max(0, G - E)``, adding ``m`` moves the config by
|
||||
``max(0, m - L)`` and leaves ``L' = max(0, L - m)`` -- which keeps the config
|
||||
at ``max(E + m, G)`` without ever needing to know ``E``.
|
||||
What stays: :func:`first_done_at` (shown by the preview and the reset log)
|
||||
and the lift file. The lift is ``0`` on every day now (no floor lifts the
|
||||
config above the earned sum), but ``plan_absorb`` still spends a stale one
|
||||
recorded before the change, once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -28,13 +21,10 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
|
||||
from screen_locker._constants import GRACE_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm
|
||||
from screen_locker._earned import first_credit_time, gate_earners
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._weekly_check import credit_key, day_workout_index
|
||||
from screen_locker._workout_ledger import entry_done_at
|
||||
|
||||
@ -96,17 +86,6 @@ def first_done_at(
|
||||
return min(on_day, default=None)
|
||||
|
||||
|
||||
def grace_for(first_done: float | None, day: date) -> int | None:
|
||||
"""The floor for ``day`` in minutes after midnight, or ``None`` if none."""
|
||||
if first_done is None:
|
||||
return None
|
||||
moment = datetime.fromtimestamp(first_done).astimezone()
|
||||
if moment.date() != day:
|
||||
return None
|
||||
done = moment.hour * 60 + moment.minute
|
||||
return min(ceiling(day), done + GRACE_MINUTES)
|
||||
|
||||
|
||||
def load_lift(state_file: Path, today: str) -> int:
|
||||
"""Today's recorded floor lift (grace or sick), 0 when none or unreadable."""
|
||||
if not state_file.exists():
|
||||
@ -146,57 +125,3 @@ def tonight_minutes(config: tuple[int, int, int], day: date) -> int:
|
||||
"""``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config."""
|
||||
mon_wed, thu_sun, _ = config
|
||||
return mon_wed if day.weekday() in _MON_WED else thu_sun
|
||||
|
||||
|
||||
class GraceFloorMixin:
|
||||
"""Lifts tonight's shutdown to the grace floor; composed into ShutdownMixin."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||
) -> bool: ...
|
||||
|
||||
def _apply_grace_floor(
|
||||
self, log_file: Path | None, *, first_done: float | None = None
|
||||
) -> bool:
|
||||
"""Lift tonight's shutdown to the grace floor if it sits below it.
|
||||
|
||||
``first_done`` overrides the ledgers' answer (the preview script's
|
||||
simulations). Returns True when the config was raised on this call.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
# The sick floor (20:00, _sick_floor) stands in for it that day.
|
||||
_logger.info("Grace floor: skipped, today is a sick day")
|
||||
return False
|
||||
if first_done is None:
|
||||
first_done = first_done_at(day, log_file, rest_day=is_rest_day(day))
|
||||
floor = grace_for(first_done, day)
|
||||
if floor is None:
|
||||
return False
|
||||
with bonus_lock(GRACE_STATE_FILE):
|
||||
config = self._read_shutdown_config()
|
||||
if config is None:
|
||||
_logger.warning("Grace floor: shutdown config unreadable; not applied")
|
||||
return False
|
||||
current = tonight_minutes(config, day)
|
||||
if current >= floor:
|
||||
return False
|
||||
mon_wed, thu_sun, morning = config
|
||||
if not self._write_shutdown_config(
|
||||
max(mon_wed, floor), max(thu_sun, floor), morning, restore=True
|
||||
):
|
||||
_logger.warning("Grace floor: failed to write shutdown config")
|
||||
return False
|
||||
lift = load_lift(GRACE_STATE_FILE, today_str()) + floor - current
|
||||
save_lift(lift, GRACE_STATE_FILE)
|
||||
_logger.info(
|
||||
"Grace floor: shutdown %s -> %s (first task done + %d min).",
|
||||
hhmm(current),
|
||||
hhmm(floor),
|
||||
GRACE_MINUTES,
|
||||
)
|
||||
return True
|
||||
|
||||
103
screen_locker/_late_credit.py
Normal file
103
screen_locker/_late_credit.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""A credit at or after tonight's shutdown counts from its own time.
|
||||
|
||||
Shutting down must never make a finished task impossible to submit (kuhy,
|
||||
2026-10-17). The config is read-add-write, so a credit landing after the
|
||||
shutdown used to add its minutes to a time already past: LeetCode at 19:00
|
||||
after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such
|
||||
a credit moves tonight to ``its own time + its reward``, capped at the
|
||||
ceiling -- only the ceiling (23:00) is hard.
|
||||
|
||||
The anchor is the credit's own time (a run's end, a ledger row's), never the
|
||||
moment a sync applied it, so a late upload does not buy extra time. Past the
|
||||
shutdown the grace and sick lifts are spent: the caller clears the lift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, hhmm
|
||||
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
"""The clock a credit without its own time is stamped with."""
|
||||
return time.time()
|
||||
|
||||
|
||||
def late_tonight(
|
||||
config: tuple[int, int, int], day: date, at: float, minutes: int
|
||||
) -> int | None:
|
||||
"""Tonight's new shutdown for a late credit, or ``None`` if it is not late.
|
||||
|
||||
Late means timed on ``day`` at or after tonight's shutdown: the check
|
||||
script locks at ``now >= shutdown``, so a credit exactly on it is late.
|
||||
"""
|
||||
moment = datetime.fromtimestamp(at).astimezone()
|
||||
if moment.date() != day:
|
||||
return None
|
||||
done = moment.hour * 60 + moment.minute
|
||||
if done < tonight_minutes(config, day):
|
||||
return None
|
||||
return min(ceiling(day), done + minutes)
|
||||
|
||||
|
||||
class LateCreditMixin:
|
||||
"""Late credits and sick-day credits; composed into ShutdownMixin."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||
) -> bool: ...
|
||||
|
||||
def _apply_sick_floor(self) -> bool: ...
|
||||
|
||||
def _raise_on_sick_day(self, minutes: int, at: float | None) -> bool:
|
||||
"""A sick day's credit: raise to the derived target, never add.
|
||||
|
||||
The credit's row is already in the target (``_sick_floor``), so adding
|
||||
its minutes on top would pay it twice; a late one still counts from
|
||||
its own time. True even when nothing moved (capped, or already there):
|
||||
the credit is accounted for, and a failed write is retried by the next
|
||||
tick's sick pass.
|
||||
"""
|
||||
self._apply_sick_floor()
|
||||
late = self._late_write(minutes, at)
|
||||
return True if late is None else late
|
||||
|
||||
def _late_write(self, minutes: int, at: float | None) -> bool | None:
|
||||
"""Write a late credit's shutdown; ``None`` when it is not late."""
|
||||
day = date.fromisoformat(today_str())
|
||||
try:
|
||||
config = self._read_shutdown_config()
|
||||
except (OSError, ValueError) as exc:
|
||||
# The add path reads it again inside its own guard and fails there.
|
||||
_logger.warning("Late credit: cannot read shutdown config (%s)", exc)
|
||||
return None
|
||||
if config is None:
|
||||
return None # the add path reads it again and reports it
|
||||
target = late_tonight(config, day, _now() if at is None else at, minutes)
|
||||
if target is None:
|
||||
return None
|
||||
mon_wed, thu_sun, morning = config
|
||||
if not self._write_shutdown_config(
|
||||
max(mon_wed, target), max(thu_sun, target), morning, restore=True
|
||||
):
|
||||
_logger.warning("Late credit: failed to write shutdown config")
|
||||
return False
|
||||
save_lift(0) # past the shutdown, the floors' lift is spent
|
||||
_logger.info(
|
||||
"Late credit: shutdown %s -> %s (earned at or after tonight's).",
|
||||
hhmm(tonight_minutes(config, day)),
|
||||
hhmm(target),
|
||||
)
|
||||
return True
|
||||
@ -15,7 +15,8 @@ from screen_locker._constants import (
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
|
||||
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||
from screen_locker._late_credit import LateCreditMixin
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick_floor import SickFloorMixin
|
||||
|
||||
@ -68,18 +69,23 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||
return mon_wed, thu_sun, morning_end
|
||||
|
||||
|
||||
class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
"""Mixin providing shutdown schedule adjustment functionality."""
|
||||
|
||||
def _adjust_shutdown_time_later(self) -> bool:
|
||||
def _adjust_shutdown_time_later(self, at: float | None = None) -> bool:
|
||||
"""Push shutdown later by the first workout's reward, capped at the ceiling.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
``at`` is when the workout ended (default now): a late one counts from
|
||||
it (:mod:`._late_credit`). Returns True if successful, False otherwise.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
reward = first_minutes(earned_time.WORKOUT, day)
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day()
|
||||
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
|
||||
return self._raise_on_sick_day(reward, at)
|
||||
late = self._late_write(reward, at)
|
||||
if late is not None:
|
||||
return late
|
||||
bonus, lift = plan_absorb(reward)
|
||||
try:
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values is None:
|
||||
@ -98,18 +104,24 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool:
|
||||
def _adjust_shutdown_time_by(
|
||||
self, extra_minutes: int, at: float | None = None
|
||||
) -> bool:
|
||||
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
|
||||
|
||||
Used for earner and extra-workout bonuses. A cap of midnight works
|
||||
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
|
||||
via the morning-window condition; the helper clamps it to 23:00 anyway.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
``at`` is when the credit was earned (default now): a late one counts
|
||||
from it (:mod:`._late_credit`). Returns True if successful.
|
||||
"""
|
||||
if is_sick(date.fromisoformat(today_str())):
|
||||
return self._raise_on_sick_day()
|
||||
# Paid out of the grace lift first, so the floor is never stacked on.
|
||||
return self._raise_on_sick_day(extra_minutes, at)
|
||||
late = self._late_write(extra_minutes, at)
|
||||
if late is not None:
|
||||
return late
|
||||
# A stale lift recorded before the grace floor went is spent first.
|
||||
moved, lift = plan_absorb(extra_minutes)
|
||||
try:
|
||||
config_values = self._read_shutdown_config()
|
||||
@ -131,17 +143,6 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _raise_on_sick_day(self) -> bool:
|
||||
"""A sick day's credit: raise to the derived target, never add.
|
||||
|
||||
The credit's row is already in the target (``_sick_floor``), so adding
|
||||
its minutes on top would pay it twice. True even when nothing moved
|
||||
(capped, or already there): the credit is accounted for, and a failed
|
||||
write is retried by the next tick's sick pass.
|
||||
"""
|
||||
self._apply_sick_floor()
|
||||
return True
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
|
||||
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
||||
|
||||
@ -18,10 +18,9 @@ followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
|
||||
disk. Any bonus source that is not a term of this derivation has that bug;
|
||||
registering it in ``earned_time`` makes it one.
|
||||
|
||||
A sick day (``sickdays``) is resolved with ``sick=True``: shutdown is at least
|
||||
20:00, and the lift above the earned sum is saved like the grace lift. A day
|
||||
marked sick after the reset is lifted by the live pass
|
||||
(:mod:`screen_locker._sick_floor`).
|
||||
A sick day (``sickdays``) is resolved with ``sick=True``: 20:00 plus the
|
||||
gaming pay of the day's credits, with no lift saved. A day marked sick after
|
||||
the reset is raised by the live pass (:mod:`screen_locker._sick_floor`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -38,6 +37,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import (
|
||||
counted_gate_earners,
|
||||
earned_today,
|
||||
first_credit_time,
|
||||
first_credits,
|
||||
first_minutes,
|
||||
flat_earners,
|
||||
@ -166,7 +166,7 @@ def _reset(
|
||||
_logger.warning("Daily base reset: failed to write shutdown config.")
|
||||
return False
|
||||
|
||||
# Later additions are paid out of the floor lift (grace or sick) first.
|
||||
# 0 now that no floor lifts the sum; overwrites a stale lift from before.
|
||||
save_lift(derived.lift)
|
||||
|
||||
# The bonuses the reset already included are stamped, so the live pass
|
||||
@ -179,13 +179,12 @@ def _reset(
|
||||
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
|
||||
)
|
||||
_logger.info(
|
||||
"Daily base reset: %s (base %s + %s already earned today%s%s%s).",
|
||||
"Daily base reset: %s (base %s + %s already earned today%s%s).",
|
||||
hhmm(target),
|
||||
hhmm(day.base.shutdown_minutes),
|
||||
earned,
|
||||
"; rest day" if derived.rest_day else "",
|
||||
"; sick day" if day.sick else "",
|
||||
f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "",
|
||||
)
|
||||
return True
|
||||
|
||||
@ -218,8 +217,10 @@ def _apply_flat_bonus(
|
||||
return False
|
||||
if not answer:
|
||||
return False
|
||||
minutes = first_minutes(earner, date.fromisoformat(today))
|
||||
if not mixin._adjust_shutdown_time_by(minutes):
|
||||
day = date.fromisoformat(today)
|
||||
minutes = first_minutes(earner, day)
|
||||
# Anchored on the credit's own row, so a late solve counts from its time.
|
||||
if not mixin._adjust_shutdown_time_by(minutes, at=first_credit_time(earner, day)):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
|
||||
return False
|
||||
state[stamp] = today
|
||||
@ -242,7 +243,6 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
|
||||
state = _load_state(state_file)
|
||||
if apply_counted_bonus(state, adjust, earner, today_str()):
|
||||
_save_state(state_file, state)
|
||||
# Last, so the floors compare against everything earned so far. The
|
||||
# sick floor covers a day marked sick after today's reset.
|
||||
# Last, so the sick target sees everything earned so far; it covers a
|
||||
# day marked sick after today's reset.
|
||||
mixin._apply_sick_floor()
|
||||
mixin._apply_grace_floor(getattr(mixin, "log_file", None))
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"""Tonight's shutdown, derived from scratch: earned time, rest day, grace floor.
|
||||
"""Tonight's shutdown, derived from scratch: earned time and rest day.
|
||||
|
||||
:func:`derive` is pure, and is the one formula behind both the daily reset
|
||||
(:mod:`screen_locker._shutdown_base`) and the read-only preview
|
||||
(``scripts/preview_shutdown.py``), so a dry run exercises the real sum::
|
||||
|
||||
earned = earned_time.resolve(answers, sick=...) # base + every earner, capped;
|
||||
# sick: at least 20:00
|
||||
target = min(ceiling, max(earned, grace floor)) # no grace floor when sick
|
||||
target = earned_time.resolve(answers, sick=...) # base + every earner, capped;
|
||||
# sick: 20:00 + gaming pay
|
||||
|
||||
A late credit (``_late_credit``) is live-only: it restarts tonight from its own
|
||||
time, and the next day's reset re-derives from scratch.
|
||||
|
||||
A declared rest day answers the workout with at least one unit, i.e. its
|
||||
first-unit bonus; a real workout on top of it earns only what a further unit
|
||||
@ -20,8 +22,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._earned import ceiling, first_credits, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at, grace_for
|
||||
from screen_locker._earned import first_credits, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._sick import is_sick
|
||||
@ -42,14 +44,12 @@ class Target:
|
||||
day raised to the 20:00 sick floor).
|
||||
rest_day: Whether the day is a declared rest day.
|
||||
first_done_at: Unix time the day's first earner was done, if any.
|
||||
grace: The grace floor in minutes, or ``None``.
|
||||
minutes: The shutdown to write, minutes after midnight.
|
||||
"""
|
||||
|
||||
resolution: earned_time.Resolution
|
||||
rest_day: bool
|
||||
first_done_at: float | None
|
||||
grace: int | None
|
||||
minutes: int
|
||||
|
||||
@property
|
||||
@ -59,7 +59,7 @@ class Target:
|
||||
|
||||
@property
|
||||
def lift(self) -> int:
|
||||
"""How far the floors (grace or sick) lift the earned shutdown.
|
||||
"""How far a floor lifts the earned shutdown: 0 since the grace floor went.
|
||||
|
||||
Saved as the day's lift (``_grace_floor.save_lift``), so a credit that
|
||||
lands later is paid out of it first instead of stacking on 20:00.
|
||||
@ -86,7 +86,8 @@ class DayInputs:
|
||||
rest_day: Declared rest day.
|
||||
first_done: Unix time the day's first earner was done, if any.
|
||||
sick_day: Sick day (``sickdays``): shutdown is at least 20:00
|
||||
(``earned_time.resolve(..., sick=True)``) and no grace floor.
|
||||
plus the gaming pay of the credits
|
||||
(``earned_time.resolve(..., sick=True)``).
|
||||
first_credits: Each penalised gate's first real credit
|
||||
(:func:`~screen_locker._earned.first_credits`); ``None`` resolves
|
||||
on ``penalty_from`` alone, as before earned_time 0.6.
|
||||
@ -102,7 +103,7 @@ class DayInputs:
|
||||
|
||||
|
||||
def derive(inputs: DayInputs) -> Target:
|
||||
"""The pure formula: earned time, then the grace floor, then the ceiling."""
|
||||
"""The pure formula: earned time, capped (a sick day stacks on 20:00)."""
|
||||
day = inputs.day
|
||||
answers: dict[str, int | bool | None] = dict(inputs.flat)
|
||||
answers[earned_time.WORKOUT.name] = workout_units(
|
||||
@ -118,16 +119,11 @@ def derive(inputs: DayInputs) -> Target:
|
||||
if starts is None
|
||||
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick)
|
||||
)
|
||||
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
|
||||
minutes = resolution.shutdown_minutes
|
||||
if grace is not None:
|
||||
minutes = min(ceiling(day), max(minutes, grace))
|
||||
return Target(
|
||||
resolution=resolution,
|
||||
rest_day=inputs.rest_day,
|
||||
first_done_at=inputs.first_done,
|
||||
grace=grace,
|
||||
minutes=minutes,
|
||||
minutes=resolution.shutdown_minutes,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import extra_minutes
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts
|
||||
from screen_locker._workout_ledger import entry_done_at
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -66,12 +67,22 @@ class WorkoutCreditMixin:
|
||||
workout_data: dict[str, str]
|
||||
log_file: Path
|
||||
|
||||
def _done_at(self, day: date) -> float | None:
|
||||
"""When this workout ended; ``None`` (now) when it records no end.
|
||||
|
||||
``workout_data`` is the entry's inner dict, so it is wrapped as the
|
||||
log entry ``entry_done_at`` reads. The run's own end anchors a late
|
||||
workout, never the moment a sync applied it.
|
||||
"""
|
||||
return entry_done_at({"workout_data": self.workout_data}, day)
|
||||
|
||||
def _try_adjust_shutdown_for_workout(self) -> bool:
|
||||
"""Try to adjust shutdown time later for actual workouts."""
|
||||
workout_type = self.workout_data.get("type", "")
|
||||
if workout_type not in COUNTED_WORKOUT_TYPES:
|
||||
return False
|
||||
adjusted = self._adjust_shutdown_time_later()
|
||||
day = date.fromisoformat(today_str())
|
||||
adjusted = self._adjust_shutdown_time_later(at=self._done_at(day))
|
||||
if adjusted:
|
||||
_logger.info("Shutdown time moved later as workout reward")
|
||||
return adjusted
|
||||
@ -141,7 +152,7 @@ class WorkoutCreditMixin:
|
||||
slot is CreditSlot.FURTHER
|
||||
and extra
|
||||
and before
|
||||
and self._adjust_shutdown_time_by(extra)
|
||||
and self._adjust_shutdown_time_by(extra, at=self._done_at(day))
|
||||
):
|
||||
new_cfg = self._read_shutdown_config()
|
||||
if new_cfg:
|
||||
|
||||
32
screen_locker/tests/_late_credit_fixtures.py
Normal file
32
screen_locker/tests/_late_credit_fixtures.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Pinning the clock an untimed credit is stamped with.
|
||||
|
||||
Re-exported from ``conftest`` rather than defined there so neither file
|
||||
exceeds the repo's line limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _late_credit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _untimed_credits_land_at_noon() -> Iterator[None]:
|
||||
"""A credit with no time of its own (``at=None``) lands at today's noon.
|
||||
|
||||
``at=None`` means "now", and a suite run after a test's configured
|
||||
shutdown (18:00, say) made every such credit late: the same tests went red
|
||||
at 18:01. Noon is before any shutdown, so only tests that pass ``at=``
|
||||
(or re-patch ``_late_credit._now``) take the late path.
|
||||
"""
|
||||
noon = datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0, second=0)
|
||||
with patch.object(_late_credit, "_now", return_value=noon.timestamp()):
|
||||
yield
|
||||
@ -28,6 +28,7 @@ from screen_locker.tests._gatelock_fixtures import (
|
||||
dual_output,
|
||||
)
|
||||
from screen_locker.tests._isolated_state import ISOLATED_STATE
|
||||
from screen_locker.tests._late_credit_fixtures import _untimed_credits_land_at_noon
|
||||
from screen_locker.tests._locker_factories import (
|
||||
_make_locker,
|
||||
create_locker,
|
||||
@ -47,15 +48,15 @@ from screen_locker.tests._tk_mocks import (
|
||||
_make_mock_tk,
|
||||
)
|
||||
|
||||
# Re-exported so pytest still resolves these by name: a fixture defined in a
|
||||
# sibling module is only visible to tests once conftest imports it. __all__
|
||||
# keeps ruff from pruning them as unused.
|
||||
# Re-exported: a sibling module's fixture is visible to tests only once
|
||||
# conftest imports it, and __all__ keeps ruff from pruning them as unused.
|
||||
__all__ = [
|
||||
"FAKE_OUTPUTS",
|
||||
"TWO_OUTPUTS",
|
||||
"_hermetic_gatelock",
|
||||
"_make_locker",
|
||||
"_mock_sys_exit",
|
||||
"_untimed_credits_land_at_noon",
|
||||
"create_locker",
|
||||
"create_locker_early_bird",
|
||||
"create_locker_relaxed_day",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for the grace floor, its lift bookkeeping and the derived target."""
|
||||
"""Tests for when the day started and the lift bookkeeping (the floor is gone)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -10,23 +10,20 @@ from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _earned, _grace_floor
|
||||
from screen_locker import _grace_floor
|
||||
from screen_locker._grace_floor import (
|
||||
GraceFloorMixin,
|
||||
first_done_at,
|
||||
grace_for,
|
||||
plan_absorb,
|
||||
save_lift,
|
||||
workout_done_at,
|
||||
)
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_DAY = date(2026, 10, 10)
|
||||
_ISO = _DAY.isoformat()
|
||||
_FLAT = {"leetcode": False, "reading": False, "anki": False, "tutor": False}
|
||||
@ -99,18 +96,6 @@ class TestWhenTheDayStarted:
|
||||
assert first_done_at(_DAY, None) is None
|
||||
|
||||
|
||||
class TestGraceFor:
|
||||
def test_an_hour_after_the_first_task(self) -> None:
|
||||
assert grace_for(at("18:55"), _DAY) == 19 * 60 + 55
|
||||
|
||||
def test_capped_at_the_ceiling(self) -> None:
|
||||
assert grace_for(at("22:40"), _DAY) == _earned.ceiling(_DAY)
|
||||
|
||||
def test_nothing_done_or_another_day_is_no_floor(self) -> None:
|
||||
assert grace_for(None, _DAY) is None
|
||||
assert grace_for(at("12:00", _DAY - timedelta(1)), _DAY) is None
|
||||
|
||||
|
||||
class TestLift:
|
||||
def test_absorb_pays_from_the_lift_first(self) -> None:
|
||||
with patch.object(_grace_floor, "today_str", return_value=_ISO):
|
||||
@ -139,71 +124,3 @@ class TestLift:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
save_lift(5, tmp_path / "missing-dir" / "g.json")
|
||||
assert "Could not record the grace lift" in caplog.text
|
||||
|
||||
|
||||
class Host(GraceFloorMixin):
|
||||
"""The mixin over an in-memory config."""
|
||||
|
||||
def __init__(self, config: tuple[int, int, int] | None, *, ok: bool = True) -> None:
|
||||
self.config = config
|
||||
self.ok = ok
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
return self.config
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
|
||||
) -> bool:
|
||||
assert restore
|
||||
if self.ok:
|
||||
self.config = (mon_wed, thu_sun, morning_end)
|
||||
return self.ok
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saturday() -> Any:
|
||||
with patch.object(_grace_floor, "today_str", return_value=_ISO):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("saturday")
|
||||
class TestApply:
|
||||
def test_lifts_to_the_floor_and_records_the_lift(self) -> None:
|
||||
host = Host((19 * 60 + 25, 19 * 60 + 25, 300))
|
||||
assert host._apply_grace_floor(None, first_done=at("18:55")) is True
|
||||
assert host.config == (19 * 60 + 55, 19 * 60 + 55, 300)
|
||||
assert plan_absorb(0) == (0, 30)
|
||||
|
||||
def test_already_above_the_floor(self) -> None:
|
||||
host = Host((21 * 60, 21 * 60, 300))
|
||||
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
|
||||
|
||||
def test_no_first_task_no_floor(self) -> None:
|
||||
with patch.object(_grace_floor, "first_done_at", return_value=None):
|
||||
assert Host((1140, 1140, 300))._apply_grace_floor(None) is False
|
||||
|
||||
def test_unreadable_config(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert Host(None)._apply_grace_floor(None, first_done=at("18:55")) is False
|
||||
assert "config unreadable" in caplog.text
|
||||
|
||||
def test_failed_write(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
host = Host((1140, 1140, 300), ok=False)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
|
||||
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)
|
||||
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)
|
||||
|
||||
171
screen_locker/tests/test_late_credit.py
Normal file
171
screen_locker/tests/test_late_credit.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""A credit that lands after tonight's shutdown restarts the clock from it.
|
||||
|
||||
kuhy, 2026-10-17: shutting down must never make a finished task impossible to
|
||||
submit. A credit timed at or after tonight's shutdown moves it to the
|
||||
credit's own time plus its reward (capped at the ceiling), so LeetCode done
|
||||
at 19:00 after an 18:00 shutdown means 20:00. Anchored on the credit's time,
|
||||
never the sync's: a workout done at 17:00 and uploaded at 19:00 is not late.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _grace_floor, _late_credit, _shutdown, _sick_floor
|
||||
from screen_locker._earned import first_minutes
|
||||
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
# Captured before conftest pins it to noon for every test.
|
||||
_REAL_NOW = _late_credit._now
|
||||
|
||||
_DAY = date(2026, 10, 17) # a Saturday: the Thu-Sun band is tonight's
|
||||
_ISO = _DAY.isoformat()
|
||||
|
||||
|
||||
def at(hhmm: str, day: date = _DAY) -> float:
|
||||
"""Unix time of ``hhmm`` local on ``day``."""
|
||||
hour, minute = (int(p) for p in hhmm.split(":"))
|
||||
return datetime(day.year, day.month, day.day, hour, minute).astimezone().timestamp()
|
||||
|
||||
|
||||
def host_workout_minutes() -> int:
|
||||
return first_minutes(earned_time.WORKOUT, _DAY)
|
||||
|
||||
|
||||
def m(hhmm: str) -> int:
|
||||
hour, minute = (int(p) for p in hhmm.split(":"))
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
class Host(ShutdownMixin):
|
||||
"""The real add paths over an in-memory config."""
|
||||
|
||||
def __init__(self, tonight: str, *, ok: bool = True) -> None:
|
||||
self.config: tuple[int, int, int] = (m(tonight), m(tonight), 300)
|
||||
self.ok = ok
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
return self.config
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
|
||||
) -> bool:
|
||||
assert restore
|
||||
if self.ok:
|
||||
self.config = (mon_wed, thu_sun, morning_end)
|
||||
return self.ok
|
||||
|
||||
@property
|
||||
def tonight(self) -> int:
|
||||
return self.config[1]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _on_the_day() -> Iterator[Any]:
|
||||
with (
|
||||
patch.object(_sick_floor, "today_str", return_value=_ISO),
|
||||
patch.object(_grace_floor, "today_str", return_value=_ISO),
|
||||
patch.object(_shutdown, "today_str", return_value=_ISO),
|
||||
patch.object(_late_credit, "today_str", return_value=_ISO),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
class TestLateCredit:
|
||||
def test_a_credit_before_the_shutdown_adds_as_before(self) -> None:
|
||||
host = Host("20:00")
|
||||
assert host._adjust_shutdown_time_by(60, at=at("18:30")) is True
|
||||
assert host.tonight == m("21:00")
|
||||
|
||||
def test_a_credit_after_the_shutdown_counts_from_its_own_time(self) -> None:
|
||||
host = Host("18:00")
|
||||
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is True
|
||||
assert host.tonight == m("20:00")
|
||||
assert host.config[0] == m("20:00") # both bands, like the floors
|
||||
|
||||
def test_a_credit_exactly_at_the_shutdown_is_late(self) -> None:
|
||||
"""The check script locks at ``now >= shutdown``."""
|
||||
host = Host("19:00")
|
||||
host._adjust_shutdown_time_by(60, at=at("19:00"))
|
||||
assert host.tonight == m("20:00")
|
||||
|
||||
def test_capped_at_the_ceiling(self) -> None:
|
||||
host = Host("22:30")
|
||||
host._adjust_shutdown_time_by(120, at=at("22:45"))
|
||||
assert host.tonight == m("23:00")
|
||||
|
||||
def test_after_the_ceiling_nothing_moves_past_it(self) -> None:
|
||||
host = Host("23:00")
|
||||
host._adjust_shutdown_time_by(60, at=at("23:10"))
|
||||
assert host.tonight == m("23:00")
|
||||
|
||||
def test_a_late_upload_is_not_a_late_credit(self) -> None:
|
||||
"""Done at 17:00, synced at 19:00: 18:00 + 2 h, not 19:00 + 2 h."""
|
||||
host = Host("18:00")
|
||||
assert host._adjust_shutdown_time_later(at=at("17:00")) is True
|
||||
assert host.tonight == m("18:00") + host_workout_minutes()
|
||||
|
||||
def test_a_late_workout_counts_from_its_end(self) -> None:
|
||||
host = Host("18:00")
|
||||
host._adjust_shutdown_time_later(at=at("19:00"))
|
||||
assert host.tonight == min(m("23:00"), m("19:00") + host_workout_minutes())
|
||||
|
||||
def test_a_credit_timed_on_another_day_is_never_late(self) -> None:
|
||||
host = Host("18:00")
|
||||
host._adjust_shutdown_time_by(60, at=at("19:00", date(2026, 10, 16)))
|
||||
assert host.tonight == m("19:00")
|
||||
|
||||
def test_no_time_means_now(self) -> None:
|
||||
host = Host("18:00")
|
||||
with patch.object(_late_credit, "_now", return_value=at("19:30")):
|
||||
host._adjust_shutdown_time_by(60)
|
||||
assert host.tonight == m("20:30")
|
||||
|
||||
def test_now_is_the_wall_clock(self) -> None:
|
||||
with patch.object(_late_credit.time, "time", return_value=123.0):
|
||||
assert _REAL_NOW() == 123.0
|
||||
|
||||
def test_a_late_credit_clears_the_lift(self) -> None:
|
||||
"""Past the shutdown the floor is spent: nothing is left to absorb."""
|
||||
save_lift(30)
|
||||
host = Host("18:00")
|
||||
host._adjust_shutdown_time_by(60, at=at("19:00"))
|
||||
assert host.tonight == m("20:00")
|
||||
assert plan_absorb(60) == (60, 0)
|
||||
|
||||
def test_a_failed_write_keeps_the_lift(self) -> None:
|
||||
save_lift(30)
|
||||
host = Host("18:00", ok=False)
|
||||
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False
|
||||
assert plan_absorb(0) == (0, 30)
|
||||
|
||||
def test_unreadable_config_fails(self) -> None:
|
||||
host = Host("18:00")
|
||||
with patch.object(Host, "_read_shutdown_config", return_value=None):
|
||||
assert host._adjust_shutdown_time_by(60, at=at("19:00")) is False
|
||||
|
||||
|
||||
class TestLateOnASickDay:
|
||||
def test_late_after_the_sick_target(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host = Host("20:00")
|
||||
with patch.object(_sick_floor, "sick_target", return_value=m("20:00")):
|
||||
assert host._adjust_shutdown_time_by(60, at=at("21:00")) is True
|
||||
assert host.tonight == m("22:00")
|
||||
|
||||
def test_not_late_raises_to_the_target_only(self) -> None:
|
||||
mark_sick(_DAY)
|
||||
host = Host("21:00")
|
||||
with patch.object(_sick_floor, "sick_target", return_value=m("22:00")):
|
||||
host._adjust_shutdown_time_by(60, at=at("15:00"))
|
||||
assert host.tonight == m("22:00")
|
||||
@ -3,12 +3,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
import pathlib
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
@ -27,6 +29,13 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _today_at(hhmm: str) -> float:
|
||||
"""Unix time of ``hhmm`` local on the locker's today."""
|
||||
hours, minutes = (int(p) for p in hhmm.split(":"))
|
||||
day = date.fromisoformat(today_str())
|
||||
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestApplyWorkoutCredit:
|
||||
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.
|
||||
@ -92,7 +101,7 @@ class TestApplyWorkoutCredit:
|
||||
``test_additional_verified_workout_earns_plus_one_hour``.
|
||||
"""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.workout_data = {"type": "manual_workout"}
|
||||
locker.workout_data = {"type": "manual_workout", "end_time": "17:00"}
|
||||
prior = [{"workout_data": {"type": "manual_workout"}}]
|
||||
object.__setattr__(
|
||||
locker,
|
||||
@ -121,7 +130,9 @@ class TestApplyWorkoutCredit:
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 60
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(
|
||||
60, at=_today_at("17:00")
|
||||
)
|
||||
|
||||
def test_additional_verified_workout_earns_plus_one_hour(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -162,4 +173,4 @@ class TestApplyWorkoutCredit:
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 60
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
|
||||
@ -114,14 +114,15 @@ class TestUnlockScreenShutdownAdjustment:
|
||||
"""Test unlock_screen adjusts shutdown for phone-verified workout."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "log.json"
|
||||
locker.workout_data = {"type": "phone_verified"}
|
||||
locker.workout_data = {"type": "phone_verified", "completed_at": "123.0"}
|
||||
object.__setattr__(
|
||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_called_once()
|
||||
# Anchored on the workout's own end, so a late upload buys nothing.
|
||||
locker._adjust_shutdown_time_later.assert_called_once_with(at=123.0)
|
||||
|
||||
def test_unlock_screen_skips_adjustment_for_sick_day(
|
||||
self,
|
||||
|
||||
@ -41,7 +41,7 @@ class TestUnlockScreenExtras:
|
||||
):
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
|
||||
def test_extra_bonus_delta_displayed(
|
||||
self,
|
||||
|
||||
@ -6,8 +6,8 @@ in ``earned_time.earners_for(day)`` is done to its full units (the tutor's
|
||||
four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)``
|
||||
-- never short of it, and never past it. Checked through screen-locker's own
|
||||
paths, not a re-implementation: the daily reset's :func:`derive`, and the live
|
||||
pass (first workout, further workouts, flat gates, counted gate blocks, grace
|
||||
floor) over :class:`ShutdownMixin` with an in-memory config.
|
||||
pass (first workout, further workouts, flat gates, counted gate blocks, each
|
||||
timed at ``first`` so a 22:30 one is a late credit) over :class:`ShutdownMixin` with an in-memory config.
|
||||
|
||||
Every gate maturity state is covered: ``first_credits`` omitted (0.5.0
|
||||
``penalty_from``), empty (every earner on its ``confirmed_on``), never paid
|
||||
@ -18,13 +18,14 @@ maturing (paid yesterday), mature, and a new tutor beside mature gates.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _gate_bonus, _grace_floor, _shutdown
|
||||
from screen_locker import _gate_bonus, _grace_floor, _late_credit, _shutdown
|
||||
from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate
|
||||
from screen_locker._earned import registry as registry_for
|
||||
from screen_locker._gate_bonus import apply_counted_bonus
|
||||
@ -148,26 +149,29 @@ def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int:
|
||||
"""Reset with nothing done, then every credit as the live pass applies it."""
|
||||
_, rest, workouts, first = case
|
||||
host = _Host(derive(_inputs(day, case, done=False)).minutes)
|
||||
first_done = _at(day, first)
|
||||
if first_done is not None:
|
||||
host._apply_grace_floor(None, first_done=first_done)
|
||||
# Every credit lands at ``first`` (09:00 when "none"): at 22:30 they are
|
||||
# late credits (_late_credit), which must still land on the ceiling.
|
||||
when = _at(day, first) or _at(day, "09:00")
|
||||
# A rest day's first unit is already in the reset; a real workout on it
|
||||
# is a further unit (_workout_credit's rest_paid rule).
|
||||
further = workouts if rest else workouts - 1
|
||||
if not rest:
|
||||
host._adjust_shutdown_time_later()
|
||||
host._adjust_shutdown_time_later(at=when)
|
||||
extra = extra_minutes(earned_time.WORKOUT, day)
|
||||
for _ in range(further if extra else 0):
|
||||
host._adjust_shutdown_time_by(extra)
|
||||
host._adjust_shutdown_time_by(extra, at=when)
|
||||
state: dict[str, Any] = {}
|
||||
for gate in (e for e in registry_for(day) if is_gate(e)):
|
||||
if gate.kind == "flat":
|
||||
host._adjust_shutdown_time_by(first_minutes(gate, day))
|
||||
host._adjust_shutdown_time_by(first_minutes(gate, day), at=when)
|
||||
continue
|
||||
for units in range(1, (gate.max_units or 1) + 1):
|
||||
with patch.object(_gate_bonus, "earned_units", return_value=units):
|
||||
apply_counted_bonus(
|
||||
state, host._adjust_shutdown_time_by, gate, day.isoformat()
|
||||
state,
|
||||
partial(host._adjust_shutdown_time_by, at=when),
|
||||
gate,
|
||||
day.isoformat(),
|
||||
)
|
||||
return host.config[1]
|
||||
|
||||
@ -178,8 +182,10 @@ def frozen_day() -> Iterator[Callable[[date], Any]]:
|
||||
|
||||
def at_day(day: date) -> Any:
|
||||
iso = day.isoformat()
|
||||
return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple(
|
||||
_grace_floor, today_str=lambda: iso, is_sick=lambda _d: False
|
||||
return (
|
||||
patch.multiple(_shutdown, today_str=lambda: iso, is_sick=lambda _d: False),
|
||||
patch.multiple(_grace_floor, today_str=lambda: iso),
|
||||
patch.multiple(_late_credit, today_str=lambda: iso),
|
||||
)
|
||||
|
||||
return at_day
|
||||
@ -197,8 +203,8 @@ def test_the_live_pass_lands_every_all_done_day_on_the_ceiling(
|
||||
case = (maturity, rest, workouts, first)
|
||||
misses = []
|
||||
for day in _DAYS:
|
||||
shutdown_patch, grace_patch = frozen_day(day)
|
||||
with shutdown_patch, grace_patch:
|
||||
shutdown_patch, grace_patch, late_patch = frozen_day(day)
|
||||
with shutdown_patch, grace_patch, late_patch:
|
||||
got = _live_pass(day, case)
|
||||
_grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True)
|
||||
if got != ceiling(day):
|
||||
|
||||
@ -89,7 +89,7 @@ class TestFlatBonuses:
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
assert json.loads(state.read_text()) == {"reading_bonus_date": today_str()}
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ class TestANewlyRegisteredEarner:
|
||||
mixin = _mixin()
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
assert json.loads(state.read_text()) == {"extra_bonus_date": today_str()}
|
||||
|
||||
def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None:
|
||||
@ -126,7 +126,7 @@ class TestANewlyRegisteredEarner:
|
||||
mixin = _mixin()
|
||||
with signing_key(tmp_path):
|
||||
apply_flat_bonuses_if_new(tmp_path / "state.json", mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
|
||||
def test_a_penalised_earner_lowers_the_base_from_its_day(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@ -149,8 +149,7 @@ class TestRealLedgers:
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None:
|
||||
"""No mock between the ledgers and the written hour."""
|
||||
# Pinned to local midnight: stamped "now", the grace floor (first
|
||||
# credit + GRACE_MINUTES) beats base+120 whenever this runs after ~19:00.
|
||||
# Pinned to local midnight, so the credits are never late ones.
|
||||
midnight = datetime.now(tz=UTC).astimezone().replace(hour=0, minute=0, second=0)
|
||||
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE, midnight)])
|
||||
write_ledger(earned_time.READING, [credit(earned_time.READING, midnight)])
|
||||
@ -169,6 +168,18 @@ class TestRealLedgers:
|
||||
"reading_bonus_date": today_str(),
|
||||
}
|
||||
|
||||
def test_the_live_pass_hands_on_the_credit_s_own_time(self, tmp_path: Path) -> None:
|
||||
"""A late credit counts from its ledger row, never the pass's clock."""
|
||||
midnight = datetime.now(tz=UTC).astimezone().replace(hour=0, minute=0, second=0)
|
||||
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE, midnight)])
|
||||
mixin = _mixin()
|
||||
with signing_key(tmp_path), answering({"leetcode": True}):
|
||||
apply_flat_bonuses_if_new(tmp_path / "state.json", mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(
|
||||
_earned.first_minutes(earned_time.LEETCODE, midnight.date()),
|
||||
at=float(int(midnight.timestamp())),
|
||||
)
|
||||
|
||||
|
||||
class TestProjectionFollowsTheCut:
|
||||
"""Every projected row is priced on its own date, not on today's base."""
|
||||
|
||||
@ -95,7 +95,7 @@ class TestLivePass:
|
||||
mixin = _mixin()
|
||||
assert apply_leetcode_bonus_if_new(state, mixin) is True
|
||||
assert apply_leetcode_bonus_if_new(state, mixin) is False
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
assert json.loads(state.read_text()) == {
|
||||
"last_reset_date": today_str(),
|
||||
"leetcode_bonus_date": today_str(),
|
||||
|
||||
@ -185,7 +185,7 @@ class TestReadingLivePass:
|
||||
mixin = _mixin()
|
||||
assert apply_reading_bonus_if_new(state, mixin) is True
|
||||
assert apply_reading_bonus_if_new(state, mixin) is False
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(60, at=None)
|
||||
assert json.loads(state.read_text()) == {
|
||||
"last_reset_date": today_str(),
|
||||
"reading_bonus_date": today_str(),
|
||||
|
||||
@ -48,16 +48,16 @@ class TestTarget:
|
||||
assert workout_units(2, rest_day=True) == 2
|
||||
assert workout_units(0, rest_day=False) == 0
|
||||
|
||||
def test_the_floor_lifts_and_the_lift_is_reported(self) -> None:
|
||||
def test_an_early_first_task_no_longer_lifts_the_sum(self) -> None:
|
||||
"""The grace floor is gone (2026-10-17): late credits replace it."""
|
||||
target = derive(
|
||||
DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55"))
|
||||
)
|
||||
assert target.minutes == max(target.earned, 19 * 60 + 55)
|
||||
assert target.lift == target.minutes - target.earned
|
||||
assert target.minutes == target.earned
|
||||
assert target.lift == 0
|
||||
|
||||
def test_a_sick_day_has_no_grace_floor_but_the_20_00_one(self) -> None:
|
||||
def test_a_sick_day_has_the_20_00_floor(self) -> None:
|
||||
target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True))
|
||||
assert target.grace is None
|
||||
assert target.resolution.sick is True
|
||||
assert target.minutes == SICK_FLOOR + sum(
|
||||
t.gaming_minutes for t in target.resolution.terms
|
||||
@ -112,5 +112,5 @@ class TestTarget:
|
||||
target = gather(_DAY, log)
|
||||
assert target.resolution.term("workout").answer == 1
|
||||
assert target.first_done_at == at("18:00")
|
||||
assert target.grace == 19 * 60
|
||||
assert target.minutes == target.earned
|
||||
assert not target.rest_day
|
||||
|
||||
@ -89,7 +89,7 @@ class SimShutdown(ShutdownMixin):
|
||||
self.config = (*clamp, morning_end)
|
||||
return True
|
||||
|
||||
def credit(self, name: str, day: date, first_done: float | None) -> None:
|
||||
def credit(self, name: str, day: date, at: float | None = None) -> None:
|
||||
"""One live pass: the earner's add (lift-absorbing), then the floors.
|
||||
|
||||
``sick`` is not an earner: it marks the (temporary) pool and runs the
|
||||
@ -99,12 +99,11 @@ class SimShutdown(ShutdownMixin):
|
||||
if name == SICK_STEP:
|
||||
sick_pool(day, sick_day=True)
|
||||
elif name == earned_time.WORKOUT.name:
|
||||
self._adjust_shutdown_time_later()
|
||||
self._adjust_shutdown_time_later(at=at)
|
||||
else:
|
||||
self._adjust_shutdown_time_by(first_minutes(earned_time.earner(name), day))
|
||||
minutes = first_minutes(earned_time.earner(name), day)
|
||||
self._adjust_shutdown_time_by(minutes, at=at)
|
||||
self._apply_sick_floor()
|
||||
if first_done is not None:
|
||||
self._apply_grace_floor(None, first_done=first_done)
|
||||
|
||||
|
||||
# The --sequence step that marks the day sick instead of crediting an earner.
|
||||
|
||||
@ -5,7 +5,7 @@ READ-ONLY on the real system: it never runs the sudo helper, never writes
|
||||
``/etc``, ``shutdown_base.json``, ``grace_floor.json``, ``rest_days.json``,
|
||||
``log.json`` or the real workout ledger. Simulations run the real code paths
|
||||
(TCX parsing + walk summing via the RunnerUp backfill, rest-day signing and
|
||||
verification, ``_shutdown_target.derive``, the grace and sick floors and their
|
||||
verification, ``_shutdown_target.derive``, the sick floor, late credits and the
|
||||
lift bookkeeping, a temporary ``sickdays`` pool) against a temporary
|
||||
directory, with an in-memory shutdown config.
|
||||
|
||||
@ -33,7 +33,7 @@ import tempfile
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker import _earned, _grace_floor, _shutdown, _sick_floor
|
||||
from screen_locker import _earned, _grace_floor, _late_credit, _shutdown, _sick_floor
|
||||
from screen_locker._constants import SHUTDOWN_CONFIG_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm
|
||||
@ -74,11 +74,10 @@ def _report(label: str, target: Target) -> None:
|
||||
if target.first_done_at
|
||||
else "-"
|
||||
)
|
||||
grace = hhmm(target.grace) if target.grace is not None else "-"
|
||||
_say(f"[{label}] {res.day} base {hhmm(res.base.shutdown_minutes)} | {terms}")
|
||||
_say(
|
||||
f" rest_day={target.rest_day} sick={res.sick} earned={hhmm(target.earned)} "
|
||||
f"first_done={first} grace={grace} lift={target.lift} "
|
||||
f"first_done={first} lift={target.lift} "
|
||||
f"-> SHUTDOWN {hhmm(target.minutes)}"
|
||||
)
|
||||
|
||||
@ -118,9 +117,15 @@ def _simulate(args: argparse.Namespace, tmp: Path) -> None:
|
||||
_report("simulated", target)
|
||||
|
||||
|
||||
def hhmm_minutes(text: str) -> int:
|
||||
"""``HH:MM`` as minutes after midnight."""
|
||||
hour, minute = (int(p) for p in text.split(":"))
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def _sequence(args: argparse.Namespace, tmp: Path) -> None:
|
||||
day = date.fromisoformat(args.day)
|
||||
for module in (_grace_floor, _shutdown, _sick_floor):
|
||||
for module in (_grace_floor, _late_credit, _shutdown, _sick_floor):
|
||||
vars(module)["today_str"] = lambda: args.day
|
||||
vars(module)["GRACE_STATE_FILE"] = tmp / "grace_floor.json"
|
||||
use_temp_pool(tmp)
|
||||
@ -128,7 +133,11 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None:
|
||||
start = derive(DayInputs(day, done))
|
||||
sim = SimShutdown(start.minutes)
|
||||
_say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done, not sick)")
|
||||
workouts, first = 0, None
|
||||
workouts, first, went_late = 0, None, False
|
||||
# The real target reads today's real ledgers; the sim's are ``done``.
|
||||
vars(_sick_floor)["sick_target"] = lambda d, _log: (
|
||||
derive(DayInputs(d, done, workouts, sick_day=True)).minutes
|
||||
)
|
||||
for step in args.sequence.split(","):
|
||||
name, _, at = step.partition("@")
|
||||
if name == earned_time.WORKOUT.name:
|
||||
@ -137,11 +146,19 @@ def _sequence(args: argparse.Namespace, tmp: Path) -> None:
|
||||
done[name] = True
|
||||
if name != SICK_STEP:
|
||||
first = min(first or stamp(day, at), stamp(day, at))
|
||||
sim.credit(name, day, first)
|
||||
late = name != SICK_STEP and hhmm_minutes(at) >= sim.config[0]
|
||||
sim.credit(name, day, stamp(day, at))
|
||||
want = derive(
|
||||
DayInputs(day, done, workouts, first_done=first, sick_day=is_sick(day))
|
||||
)
|
||||
verdict = "" if sim.config[0] == want.minutes else " MISMATCH"
|
||||
# A late credit is live-only, so from it on the derived sum is no judge.
|
||||
went_late = went_late or late
|
||||
if late:
|
||||
verdict = " LATE: from its own time"
|
||||
elif went_late:
|
||||
verdict = " live, after a late credit"
|
||||
else:
|
||||
verdict = "" if sim.config[0] == want.minutes else " MISMATCH"
|
||||
_say(
|
||||
f" {name}@{at}: config {hhmm(sim.config[0])} "
|
||||
f"(derived {hhmm(want.minutes)}{verdict})"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user