mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
earned_time 0.6.0: per-day registry consumer, sleep-ladder date-proof tests, pin v0.6.0
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
19ccad6978
commit
bbc14ec04c
@ -134,7 +134,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- 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.4.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
@ -155,7 +155,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- 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.4.0#subdirectory=earned_time
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
@ -10,7 +10,7 @@ dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
|
||||
"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.3.1#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -4,7 +4,7 @@ bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.2
|
||||
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.4.0#subdirectory=earned_time
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
mcp==2.3.0
|
||||
|
||||
@ -16,6 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -26,7 +27,7 @@ import earned_time
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
@ -61,11 +62,40 @@ def ledger_file(earner: earned_time.Earner) -> Path:
|
||||
return LEDGER_HOME / earner.ledger
|
||||
|
||||
|
||||
def flat_earners() -> tuple[earned_time.Earner, ...]:
|
||||
"""The once-per-day, ledger-backed earners (the workout is counted here)."""
|
||||
return tuple(
|
||||
e for e in earned_time.EARNERS if e.kind == "flat" and e.ledger is not None
|
||||
)
|
||||
def registry(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||
"""The earners in force on ``day`` (default today).
|
||||
|
||||
``earners_for`` (earned_time >= 0.5) switches the registry on
|
||||
``TUTOR_FROM``; an older earned_time has one static registry.
|
||||
"""
|
||||
fn = getattr(earned_time, "earners_for", None)
|
||||
return tuple(earned_time.EARNERS if fn is None else fn(day))
|
||||
|
||||
|
||||
def is_gate(earner: earned_time.Earner) -> bool:
|
||||
"""A ledger-backed earner whose gate publishes credits (not the workout).
|
||||
|
||||
The workout has a ledger too, but screen-locker writes it and
|
||||
``WorkoutCreditMixin`` applies it.
|
||||
"""
|
||||
return earner.ledger is not None and earner.name != earned_time.WORKOUT.name
|
||||
|
||||
|
||||
def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||
"""The once-per-day, ledger-backed earners in force on ``day``."""
|
||||
return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e))
|
||||
|
||||
|
||||
def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||
"""Gate earners paid per unit on ``day`` (the Automation tutor's blocks)."""
|
||||
return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e))
|
||||
|
||||
|
||||
def watched_earners() -> tuple[earned_time.Earner, ...]:
|
||||
"""Every gate earner of every registry, so a cutover misses no ledger."""
|
||||
fn = getattr(earned_time, "all_earners", None)
|
||||
pool = earned_time.EARNERS if fn is None else fn()
|
||||
return tuple(e for e in pool if is_gate(e))
|
||||
|
||||
|
||||
def earned_today(
|
||||
@ -75,20 +105,45 @@ def earned_today(
|
||||
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
|
||||
|
||||
|
||||
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
|
||||
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
|
||||
return earned_time.credit_units(earner, ledger_file(earner), HMAC_KEY_FILE, day)
|
||||
|
||||
|
||||
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
|
||||
"""Every gate earner's answer for ``day`` (default today), unknown logged.
|
||||
|
||||
Flat earners answer "done today"; counted ones (the tutor) their units.
|
||||
On an earned_time without counted gates this is exactly
|
||||
:func:`flat_answers`.
|
||||
"""
|
||||
target = day or datetime.now().astimezone().date()
|
||||
answers: dict[str, int | bool | None] = dict(flat_answers())
|
||||
for earner in counted_gate_earners(target):
|
||||
units = earned_units(earner, target)
|
||||
if units is None:
|
||||
_warn_unknown(earner)
|
||||
answers[earner.name] = units
|
||||
return answers
|
||||
|
||||
|
||||
def flat_answers() -> dict[str, bool | None]:
|
||||
"""Today's answer from every flat earner, an unknown one logged."""
|
||||
answers: dict[str, bool | None] = {}
|
||||
for earner in flat_earners():
|
||||
answer = earned_today(earner)
|
||||
if answer is None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it",
|
||||
earner.label,
|
||||
)
|
||||
_warn_unknown(earner)
|
||||
answers[earner.name] = answer
|
||||
return answers
|
||||
|
||||
|
||||
def _warn_unknown(earner: earned_time.Earner) -> None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it", earner.label
|
||||
)
|
||||
|
||||
|
||||
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
|
||||
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
|
||||
# the raw fields it always used.
|
||||
|
||||
@ -27,7 +27,7 @@ import subprocess
|
||||
import sys
|
||||
from typing import Final
|
||||
|
||||
from screen_locker._earned import flat_earners, ledger_file
|
||||
from screen_locker._earned import ledger_file, watched_earners
|
||||
|
||||
__all__ = ["PATH_UNIT_NAME", "USER_UNIT_DIR", "render_path_unit", "sync_path_unit"]
|
||||
|
||||
@ -51,8 +51,15 @@ WantedBy=default.target
|
||||
|
||||
|
||||
def render_path_unit() -> str:
|
||||
"""The unit text: one ``PathChanged=`` per flat earner's ledger."""
|
||||
watches = "".join(f"PathChanged={ledger_file(e)}\n" for e in flat_earners())
|
||||
"""The unit text: one ``PathChanged=`` per gate earner's ledger.
|
||||
|
||||
Every registry's gates, not just today's (``watched_earners``), so a
|
||||
dated cutover (the Automation tutor's ``TUTOR_FROM``) needs no re-render
|
||||
on the day. The header keeps saying "flat": changing it would rewrite the
|
||||
installed unit for no behavioural change.
|
||||
"""
|
||||
ledgers = dict.fromkeys(ledger_file(e) for e in watched_earners())
|
||||
watches = "".join(f"PathChanged={path}\n" for path in ledgers)
|
||||
return _HEADER + watches + _FOOTER
|
||||
|
||||
|
||||
|
||||
101
screen_locker/_gate_bonus.py
Normal file
101
screen_locker/_gate_bonus.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Live shutdown credit for counted gate earners: paid per unit, as it lands.
|
||||
|
||||
A flat earner (LeetCode, reading) pays once a day, stamped
|
||||
``<name>_bonus_date``. A counted gate earner -- the Automation tutor, one
|
||||
signed ledger row per verified 15-minute block -- pays per unit, so its stamp
|
||||
is ``<name>_bonus_units``: the day, and how many units the config already
|
||||
holds. The live pass pays only the difference, priced by
|
||||
``Earner.shutdown_for`` so the uneven rung split (13/13/12/12) and the
|
||||
``max_units`` cap come from the registry, never from here.
|
||||
|
||||
The workout is counted too, but screen-locker owns its log and
|
||||
``WorkoutCreditMixin`` applies it; it is never in this pass.
|
||||
|
||||
State I/O stays in :mod:`screen_locker._shutdown_base`; these functions read
|
||||
and update the state dict it hands over.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._earned import earned_units, is_gate, span
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import earned_time
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def date_stamp(earner: earned_time.Earner) -> str:
|
||||
"""The once-per-day key of a flat earner's live pass in the state file."""
|
||||
return f"{earner.name}_bonus_date"
|
||||
|
||||
|
||||
def units_stamp(earner: earned_time.Earner) -> str:
|
||||
"""The per-day key of ``earner``'s applied units in the state file."""
|
||||
return f"{earner.name}_bonus_units"
|
||||
|
||||
|
||||
def applied_units(state: dict[str, Any], earner: earned_time.Earner, today: str) -> int:
|
||||
"""Units of ``earner`` the config already holds today (0 if none stamped)."""
|
||||
raw = state.get(units_stamp(earner))
|
||||
if not isinstance(raw, dict) or raw.get("date") != today:
|
||||
return 0
|
||||
units = raw.get("units")
|
||||
return units if isinstance(units, int) and units > 0 else 0
|
||||
|
||||
|
||||
def reset_stamps(terms: Iterable[earned_time.Term], today: str) -> dict[str, object]:
|
||||
"""The stamps for what a daily reset already wrote into the config.
|
||||
|
||||
A flat earner that paid is stamped with the date, a counted gate with
|
||||
its units, so the live pass does not add either a second time today.
|
||||
"""
|
||||
stamps: dict[str, object] = {}
|
||||
for t in terms:
|
||||
if t.earner.kind == "flat" and t.shutdown_minutes:
|
||||
stamps[date_stamp(t.earner)] = today
|
||||
elif t.earner.kind == "counted" and is_gate(t.earner) and t.answer:
|
||||
stamps[units_stamp(t.earner)] = {"date": today, "units": t.answer}
|
||||
return stamps
|
||||
|
||||
|
||||
def apply_counted_bonus(
|
||||
state: dict[str, Any],
|
||||
adjust: Callable[[int], bool],
|
||||
earner: earned_time.Earner,
|
||||
today: str,
|
||||
) -> bool:
|
||||
"""Push shutdown later by ``earner``'s units credited since the last pass.
|
||||
|
||||
``adjust`` is the mixin's ``_adjust_shutdown_time_by``. Updates ``state``
|
||||
(the caller saves it) and returns True when it changed. An unreadable
|
||||
ledger or key earns nothing and is logged.
|
||||
"""
|
||||
day = date.fromisoformat(today)
|
||||
units = earned_units(earner, day)
|
||||
if units is None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it", earner.label
|
||||
)
|
||||
return False
|
||||
done = applied_units(state, earner, today)
|
||||
if units <= done:
|
||||
return False
|
||||
minutes = earner.shutdown_for(units, day) - earner.shutdown_for(done, day)
|
||||
if minutes > 0 and not adjust(minutes):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
|
||||
return False
|
||||
state[units_stamp(earner)] = {"date": today, "units": units}
|
||||
_logger.info(
|
||||
"%s bonus: %d unit(s) today, +%s shutdown time.",
|
||||
earner.label,
|
||||
units,
|
||||
span(minutes),
|
||||
)
|
||||
return True
|
||||
@ -4,12 +4,10 @@ On each new calendar day the shutdown config is reset to the day's base so
|
||||
that the day's bonuses always layer on top of a known floor rather than
|
||||
accumulating indefinitely across days.
|
||||
|
||||
The base and every bonus come from the shared earner registry
|
||||
(``earned_time``, see :mod:`screen_locker._earned`): 20:00 before any penalty,
|
||||
minus each earner's cut once its ``penalty_from`` day arrives (book-guard's
|
||||
reading hour, from 2026-10-01). Nothing here is state. The base used to be
|
||||
persisted in the state file and read back on every reset, which made the
|
||||
code's default dead; the file now carries only date stamps.
|
||||
The base and every bonus come from the shared earner registry in force that
|
||||
day (``earned_time``, see :mod:`screen_locker._earned`). Nothing here is
|
||||
state: the file carries only stamps (``<name>_bonus_date`` for a flat
|
||||
earner, ``<name>_bonus_units`` for a counted gate, :mod:`._gate_bonus`).
|
||||
|
||||
The reset re-derives what today has ALREADY earned and writes base plus that
|
||||
in one go: the workout hours from today's log and every flat earner's hour
|
||||
@ -20,8 +18,8 @@ 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.
|
||||
|
||||
The sick-day state file is cleared on reset so the sick-restore path cannot
|
||||
overwrite the fresh base when it runs later in the same startup.
|
||||
The reset clears the sick-day state file, so sick-restore cannot overwrite
|
||||
the fresh base later in the same startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -36,12 +34,15 @@ import earned_time
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import (
|
||||
counted_gate_earners,
|
||||
earned_today,
|
||||
first_minutes,
|
||||
flat_earners,
|
||||
hhmm,
|
||||
registry,
|
||||
span,
|
||||
)
|
||||
from screen_locker._gate_bonus import apply_counted_bonus, date_stamp, reset_stamps
|
||||
from screen_locker._grace_floor import save_lift
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._shutdown_target import gather
|
||||
@ -62,12 +63,7 @@ def base_minutes(day: date | None = None) -> int:
|
||||
Every penalty in force is already taken off.
|
||||
"""
|
||||
target = day or datetime.now().astimezone().date()
|
||||
return earned_time.base_for(target, earned_time.EARNERS).shutdown_minutes
|
||||
|
||||
|
||||
def _stamp(earner: earned_time.Earner) -> str:
|
||||
"""The once-per-day key of ``earner``'s live pass in the state file."""
|
||||
return f"{earner.name}_bonus_date"
|
||||
return earned_time.base_for(target, registry(target)).shutdown_minutes
|
||||
|
||||
|
||||
def today_credit_count(log_file: Path, today: str) -> int:
|
||||
@ -172,12 +168,10 @@ def _reset(
|
||||
# Later additions are paid out of the grace lift first (see _grace_floor).
|
||||
save_lift(derived.lift)
|
||||
|
||||
# The flat bonuses are stamped here too: the reset already included them,
|
||||
# so the live pass below must not add them a second time today.
|
||||
# The bonuses the reset already included are stamped, so the live pass
|
||||
# does not add them a second time today.
|
||||
new_state: dict[str, Any] = {"last_reset_date": today}
|
||||
for term in day.terms:
|
||||
if term.earner.kind == "flat" and term.shutdown_minutes:
|
||||
new_state[_stamp(term.earner)] = today
|
||||
new_state.update(reset_stamps(day.terms, today))
|
||||
_save_state(state_file, new_state)
|
||||
|
||||
earned = " + ".join(
|
||||
@ -211,7 +205,7 @@ def _apply_flat_bonus(
|
||||
"""
|
||||
today = today_str()
|
||||
state = _load_state(state_file)
|
||||
stamp = _stamp(earner)
|
||||
stamp = date_stamp(earner)
|
||||
if state.get(stamp) == today:
|
||||
return False
|
||||
answer = earned_today(earner)
|
||||
@ -235,11 +229,16 @@ def _apply_flat_bonus(
|
||||
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
|
||||
"""Every once-per-day flat bonus, in registry order (LeetCode, reading, ...).
|
||||
|
||||
The workout is not among them: it is counted, and its live credit is
|
||||
applied by :class:`~screen_locker._workout_credit.WorkoutCreditMixin`.
|
||||
Then each counted gate's new units (:mod:`._gate_bonus`); the workout's
|
||||
live credit is ``WorkoutCreditMixin``'s.
|
||||
"""
|
||||
with bonus_lock(state_file):
|
||||
for earner in flat_earners():
|
||||
_apply_flat_bonus(state_file, mixin, earner)
|
||||
adjust = mixin._adjust_shutdown_time_by
|
||||
for earner in counted_gate_earners():
|
||||
state = _load_state(state_file)
|
||||
if apply_counted_bonus(state, adjust, earner, today_str()):
|
||||
_save_state(state_file, state)
|
||||
# Last, so the floor compares against everything earned so far.
|
||||
mixin._apply_grace_floor(getattr(mixin, "log_file", None))
|
||||
|
||||
@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._earned import ceiling, flat_answers
|
||||
from screen_locker._earned import ceiling, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at, grace_for, sick_on
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
@ -71,7 +71,8 @@ class DayInputs:
|
||||
|
||||
Attributes:
|
||||
day: The day.
|
||||
flat: Each flat earner's answer (``None`` = could not check).
|
||||
flat: Each gate earner's answer: done (flat) or units (counted, the
|
||||
tutor); ``None`` = could not check.
|
||||
workout_credits: The log's distinct workout credits.
|
||||
rest_day: Declared rest day.
|
||||
first_done: Unix time the day's first earner was done, if any.
|
||||
@ -79,7 +80,7 @@ class DayInputs:
|
||||
"""
|
||||
|
||||
day: date
|
||||
flat: Mapping[str, bool | None]
|
||||
flat: Mapping[str, int | bool | None]
|
||||
workout_credits: int = 0
|
||||
rest_day: bool = False
|
||||
first_done: float | None = None
|
||||
@ -93,9 +94,9 @@ def derive(inputs: DayInputs) -> Target:
|
||||
answers[earned_time.WORKOUT.name] = workout_units(
|
||||
inputs.workout_credits, rest_day=inputs.rest_day
|
||||
)
|
||||
# The registry is passed explicitly: flat_answers iterated this same
|
||||
# tuple, so the earners asked and the earners summed can never differ.
|
||||
resolution = earned_time.resolve(answers, day, earned_time.EARNERS)
|
||||
# The registry is passed explicitly: gate_answers iterated this same
|
||||
# day's registry, so the earners asked and the earners summed match.
|
||||
resolution = earned_time.resolve(answers, day, registry(day))
|
||||
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
|
||||
minutes = resolution.shutdown_minutes
|
||||
if grace is not None:
|
||||
@ -124,7 +125,7 @@ def gather(day: date, log_file: Path | None) -> Target:
|
||||
return derive(
|
||||
DayInputs(
|
||||
day=day,
|
||||
flat=flat_answers(),
|
||||
flat=gate_answers(day),
|
||||
workout_credits=day_credit_count(log_file, day),
|
||||
rest_day=rest_day,
|
||||
first_done=first_done_at(day, log_file, rest_day=rest_day),
|
||||
|
||||
@ -27,6 +27,8 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
KEY: Final = b"test-key-bytes"
|
||||
|
||||
|
||||
@ -37,7 +39,7 @@ def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
|
||||
|
||||
|
||||
# A flat earner the registry does not have yet, for tests that register one
|
||||
# (``monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))``)
|
||||
# (``register(monkeypatch, EXTRA)``)
|
||||
# to pin that nothing in screen-locker needs code for it.
|
||||
EXTRA: Final = earned_time.Earner(
|
||||
name="extra",
|
||||
@ -49,6 +51,19 @@ EXTRA: Final = earned_time.Earner(
|
||||
)
|
||||
|
||||
|
||||
def register(monkeypatch: pytest.MonkeyPatch, *extra: earned_time.Earner) -> None:
|
||||
"""Append stand-in earners to every registry, before and after the cutover.
|
||||
|
||||
The registry in force is per day (``earned_time.earners_for``: ``EARNERS``
|
||||
before ``TUTOR_FROM``, ``TUTOR_EARNERS`` from it), so a stand-in patched
|
||||
into ``EARNERS`` alone would vanish on a tutor day.
|
||||
"""
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, *extra))
|
||||
tutor = getattr(earned_time, "TUTOR_EARNERS", None)
|
||||
if tutor is not None:
|
||||
monkeypatch.setattr(earned_time, "TUTOR_EARNERS", (*tutor, *extra))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def signing_key(tmp_path: Path, content: bytes = KEY) -> Iterator[Path]:
|
||||
"""Write ``content`` as the HMAC key and point ``_earned`` at it."""
|
||||
|
||||
@ -9,10 +9,14 @@ The two seams are genuinely different, which is why neither subsumes the
|
||||
other. ``_isolate_home`` covers paths resolved at *call* time from
|
||||
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
|
||||
constants at *import* time, which redirecting home afterwards cannot reach.
|
||||
|
||||
``pre_ladder`` is the one opt-in fixture here: it keeps a test off the real
|
||||
calendar's ladder cutover, the date-dependent half of the same question.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
@ -108,3 +112,18 @@ def _no_desktop_notifications(tmp_path: Path) -> Iterator[None]:
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Resolve every day on the pre-ladder rules (the first workout = +2h).
|
||||
|
||||
From ``earned_time.LADDER_FROM`` (2026-10-10) shutdown is read down from
|
||||
sleep and each earner's minutes change, so a test pinning the old figures
|
||||
would go red on that date by the calendar alone. ``on_ladder`` reads the
|
||||
constant at call time and every ladder decision goes through it, so moving
|
||||
it out of reach freezes the rules without faking "today" -- which comes
|
||||
from several clocks, including the ledger windows the credit rows must hit.
|
||||
Opt-in: tests that assert the ladder itself must keep the real cutover.
|
||||
"""
|
||||
monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max)
|
||||
|
||||
@ -12,6 +12,8 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _credit_slot
|
||||
from screen_locker._credit_slot import CreditSlot, credit_slot, locked_shutdown_state
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
@ -19,8 +21,6 @@ from screen_locker.tests.conftest import create_locker
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
@ -121,6 +121,7 @@ def _credit(locker: ScreenLocker, prior: list) -> tuple[WorkoutCreditResult, Mag
|
||||
return result, notify
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestSlotCreditMinutes:
|
||||
"""The minutes each slot class actually moves the shutdown."""
|
||||
|
||||
|
||||
@ -24,11 +24,13 @@ from screen_locker._earned import (
|
||||
flat_earners,
|
||||
hhmm,
|
||||
ledger_file,
|
||||
registry,
|
||||
span,
|
||||
)
|
||||
from screen_locker.tests._earned_fixtures import (
|
||||
EXTRA,
|
||||
credit,
|
||||
register,
|
||||
signing_key,
|
||||
write_ledger,
|
||||
)
|
||||
@ -104,14 +106,14 @@ class TestLedgerFile:
|
||||
|
||||
class TestFlatEarners:
|
||||
def test_registry_order_without_the_counted_workout(self) -> None:
|
||||
flat = tuple(e for e in earned_time.EARNERS if e.kind == "flat")
|
||||
flat = tuple(e for e in registry() if e.kind == "flat")
|
||||
assert flat_earners() == flat
|
||||
assert flat[:3] == (earned_time.LEETCODE, earned_time.READING, earned_time.ANKI)
|
||||
assert flat[:2] == (earned_time.LEETCODE, earned_time.READING)
|
||||
|
||||
def test_a_newly_registered_earner_is_picked_up(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
|
||||
register(monkeypatch, EXTRA)
|
||||
assert flat_earners()[-1] is EXTRA
|
||||
|
||||
def test_a_flat_earner_without_a_ledger_is_left_out(
|
||||
@ -121,7 +123,7 @@ class TestFlatEarners:
|
||||
unledgered = earned_time.Earner(
|
||||
name="manual", label="manual", gaming_minutes=60, shutdown_minutes=60
|
||||
)
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, unledgered))
|
||||
register(monkeypatch, unledgered)
|
||||
assert unledgered not in flat_earners()
|
||||
|
||||
def test_conftest_seeds_every_flat_earners_stamp(self) -> None:
|
||||
|
||||
@ -11,7 +11,7 @@ import pytest
|
||||
|
||||
from screen_locker import _earner_units
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._earned import flat_earners, ledger_file
|
||||
from screen_locker._earned import flat_earners, ledger_file, watched_earners
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_flat_bonuses_if_new,
|
||||
reset_to_base_if_new_day,
|
||||
@ -38,6 +38,13 @@ class TestRenderPathUnit:
|
||||
watched = [ln.removeprefix("PathChanged=") for ln in lines if "=" in ln]
|
||||
expected = [str(ledger_file(e)) for e in flat_earners()]
|
||||
assert [w for w in watched if w in expected] == expected
|
||||
|
||||
def test_one_path_changed_per_gate_ledger_of_every_registry(self) -> None:
|
||||
"""Today's and the cutover's gates (the tutor), each ledger once."""
|
||||
lines = _earner_units.render_path_unit().splitlines()
|
||||
watched = [ln.removeprefix("PathChanged=") for ln in lines if "=" in ln]
|
||||
expected = list(dict.fromkeys(str(ledger_file(e)) for e in watched_earners()))
|
||||
assert [w for w in watched if w.startswith("/")] == expected
|
||||
assert sum(ln.startswith("PathChanged=") for ln in lines) == len(expected)
|
||||
|
||||
def test_never_path_modified(self) -> None:
|
||||
|
||||
237
screen_locker/tests/test_gate_bonus.py
Normal file
237
screen_locker/tests/test_gate_bonus.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""Tests for counted gate earners (the Automation tutor): paid per unit, live.
|
||||
|
||||
A stand-in counted gate (``BLOCKS``) is registered by patching
|
||||
``earned_time.EARNERS``, the registry seam both the installed earned_time and
|
||||
the tutor-cutover one (``earners_for``) read, so these hold on either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import (
|
||||
counted_gate_earners,
|
||||
flat_earners,
|
||||
gate_answers,
|
||||
registry,
|
||||
watched_earners,
|
||||
)
|
||||
from screen_locker._gate_bonus import applied_units, apply_counted_bonus, reset_stamps
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_flat_bonuses_if_new,
|
||||
base_minutes,
|
||||
reset_to_base_if_new_day,
|
||||
)
|
||||
from screen_locker.tests._earned_fixtures import (
|
||||
answering,
|
||||
register,
|
||||
signed,
|
||||
signing_key,
|
||||
write_ledger,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
|
||||
del row, window
|
||||
return True
|
||||
|
||||
|
||||
# 10 minutes for the first unit, 5 for each further one, on any day: no rung.
|
||||
BLOCKS = earned_time.Earner(
|
||||
name="blocks",
|
||||
label="Blocks",
|
||||
gaming_minutes=15,
|
||||
shutdown_minutes=10,
|
||||
kind="counted",
|
||||
extra_shutdown_minutes=5,
|
||||
ledger=".local/share/blocks_guard/ledger.json",
|
||||
match=_any_credit,
|
||||
)
|
||||
TODAY = date.fromisoformat(today_str())
|
||||
|
||||
|
||||
def _block(number: int) -> dict[str, Any]:
|
||||
return signed(
|
||||
{
|
||||
"kind": "credit",
|
||||
"entry_id": f"s-b{number}",
|
||||
"day": today_str(),
|
||||
"detail": {"block": number},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _minutes(units: int) -> int:
|
||||
return BLOCKS.shutdown_for(units, TODAY)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def with_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
register(monkeypatch, BLOCKS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(tmp_path: Path) -> Iterator[Path]:
|
||||
with signing_key(tmp_path) as path:
|
||||
yield path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flat_no() -> Iterator[None]:
|
||||
with answering({}):
|
||||
yield
|
||||
|
||||
|
||||
def _mixin() -> MagicMock:
|
||||
mixin = MagicMock()
|
||||
mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300)
|
||||
mixin._write_shutdown_config.return_value = True
|
||||
mixin._adjust_shutdown_time_by.return_value = True
|
||||
return mixin
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_counted_gates_are_apart_from_flat_and_the_workout(self) -> None:
|
||||
assert counted_gate_earners()[-1] is BLOCKS
|
||||
assert earned_time.WORKOUT not in counted_gate_earners()
|
||||
assert BLOCKS not in flat_earners()
|
||||
assert earned_time.WORKOUT not in watched_earners()
|
||||
assert BLOCKS in watched_earners()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("key", "flat_no")
|
||||
class TestGateAnswers:
|
||||
def test_units_alongside_flat_answers(self) -> None:
|
||||
write_ledger(BLOCKS, [_block(1), _block(2)])
|
||||
answers = gate_answers()
|
||||
assert answers["blocks"] == 2
|
||||
assert {e.name for e in flat_earners()} <= set(answers)
|
||||
|
||||
def test_unreadable_ledger_is_none_and_logged(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
path = write_ledger(BLOCKS, [])
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
assert gate_answers(TODAY)["blocks"] is None
|
||||
assert "Blocks state could not be checked" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
class TestApplyCountedBonus:
|
||||
def test_pays_only_the_new_units(self) -> None:
|
||||
state: dict[str, Any] = {}
|
||||
adjust = MagicMock(return_value=True)
|
||||
write_ledger(BLOCKS, [_block(1), _block(2)])
|
||||
assert apply_counted_bonus(state, adjust, BLOCKS, today_str())
|
||||
write_ledger(BLOCKS, [_block(1), _block(2), _block(3)])
|
||||
assert apply_counted_bonus(state, adjust, BLOCKS, today_str())
|
||||
assert not apply_counted_bonus(state, adjust, BLOCKS, today_str())
|
||||
assert [c.args[0] for c in adjust.call_args_list] == [
|
||||
_minutes(2),
|
||||
_minutes(3) - _minutes(2),
|
||||
]
|
||||
assert state == {"blocks_bonus_units": {"date": today_str(), "units": 3}}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stamp",
|
||||
[{"date": "2000-01-01", "units": 9}, {"date": None}, "junk", {"units": "x"}],
|
||||
)
|
||||
def test_a_stale_or_broken_stamp_is_zero(self, stamp: object) -> None:
|
||||
assert applied_units({"blocks_bonus_units": stamp}, BLOCKS, today_str()) == 0
|
||||
|
||||
def test_failed_write_keeps_the_stamp(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
state: dict[str, Any] = {}
|
||||
write_ledger(BLOCKS, [_block(1)])
|
||||
adjust = MagicMock(return_value=False)
|
||||
assert not apply_counted_bonus(state, adjust, BLOCKS, today_str())
|
||||
assert state == {}
|
||||
assert "Blocks bonus: failed to write" in caplog.text
|
||||
|
||||
def test_unknown_earns_nothing_and_says_so(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
write_ledger(BLOCKS, []).write_text("[]", encoding="utf-8")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not apply_counted_bonus({}, MagicMock(), BLOCKS, today_str())
|
||||
assert "could not be checked" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("key", "flat_no")
|
||||
class TestThroughTheShutdownBase:
|
||||
def test_reset_stamps_flat_dates_and_counted_units(self) -> None:
|
||||
answers = {"workout": 1, "leetcode": True, "blocks": 2}
|
||||
terms = earned_time.resolve(answers, TODAY, registry(TODAY)).terms
|
||||
stamps = reset_stamps(terms, today_str())
|
||||
assert stamps == {
|
||||
"leetcode_bonus_date": today_str(),
|
||||
"blocks_bonus_units": {"date": today_str(), "units": 2},
|
||||
}
|
||||
|
||||
def test_reset_includes_units_then_live_pass_adds_only_new(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
state = tmp_path / "state.json"
|
||||
write_ledger(BLOCKS, [_block(1)])
|
||||
mixin = _mixin()
|
||||
assert reset_to_base_if_new_day(state, mixin) is True
|
||||
written = mixin._write_shutdown_config.call_args.args[0]
|
||||
assert written == base_minutes() + _minutes(1)
|
||||
saved = json.loads(state.read_text())
|
||||
assert saved["blocks_bonus_units"] == {"date": today_str(), "units": 1}
|
||||
write_ledger(BLOCKS, [_block(1), _block(2)])
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(
|
||||
_minutes(2) - _minutes(1)
|
||||
)
|
||||
units = json.loads(state.read_text())["blocks_bonus_units"]["units"]
|
||||
assert units == 2
|
||||
|
||||
def test_live_pass_without_new_units_writes_nothing(self, tmp_path: Path) -> None:
|
||||
state = tmp_path / "state.json"
|
||||
write_ledger(BLOCKS, [])
|
||||
mixin = _mixin()
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_not_called()
|
||||
assert not state.exists()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
def test_the_real_tutor_rung_pays_13_13_12_12_then_nothing() -> None:
|
||||
"""The registry's uneven split, through this pass, on the cutover day."""
|
||||
tutor = getattr(earned_time, "AUTOMATION_TUTOR", None)
|
||||
if tutor is None:
|
||||
pytest.skip("installed earned_time predates the tutor (< 0.5)")
|
||||
cutover = earned_time.TUTOR_FROM.isoformat()
|
||||
state: dict[str, Any] = {}
|
||||
adjust = MagicMock(return_value=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for number in range(1, 6):
|
||||
ended = datetime.combine(earned_time.TUTOR_FROM, time(18, number)).astimezone()
|
||||
rows.append(
|
||||
signed(
|
||||
{
|
||||
"kind": "credit",
|
||||
"entry_id": f"s-b{number}",
|
||||
"day": cutover,
|
||||
"detail": {"block": number, "ended_at": ended.timestamp()},
|
||||
}
|
||||
)
|
||||
)
|
||||
write_ledger(tutor, rows)
|
||||
apply_counted_bonus(state, adjust, tutor, cutover)
|
||||
assert [c.args[0] for c in adjust.call_args_list] == [13, 13, 12, 12]
|
||||
assert state["automation_bonus_units"] == {"date": cutover, "units": 5}
|
||||
@ -67,7 +67,7 @@ class TestWhenTheDayStarted:
|
||||
tmp_path,
|
||||
[{"workout_data": {"type": "manual_workout", "end_time": "19:30"}}],
|
||||
)
|
||||
times = {"anki": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
|
||||
times = {"reading": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
|
||||
with patch.object(
|
||||
_grace_floor,
|
||||
"first_credit_time",
|
||||
|
||||
@ -154,6 +154,7 @@ class TestMainEntry:
|
||||
assert locker.demo_mode is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestAdjustShutdownTimeLater:
|
||||
"""Tests for _adjust_shutdown_time_later method."""
|
||||
|
||||
|
||||
@ -7,6 +7,8 @@ import pathlib
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
@ -25,6 +27,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestApplyWorkoutCredit:
|
||||
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
@ -113,6 +115,7 @@ class TestBackfillCreditCallback:
|
||||
assert all(call.args[2] is callback for call in fill.call_args_list)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestSyncPassCreditsRunnerUp:
|
||||
"""``_auto_fill_week_runnerup_bonus`` (the 15-minute timer path)."""
|
||||
|
||||
|
||||
@ -5,12 +5,15 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker.tests._unlock_helpers import setup_unlock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestUnlockScreenExtras:
|
||||
"""unlock_screen's extra-workout bonus and streak display."""
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@ import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_base import (
|
||||
base_minutes,
|
||||
@ -57,6 +59,7 @@ def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path:
|
||||
return log_file
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestEarnedShutdownBonusMinutes:
|
||||
"""First credit is worth 2h, every further one 1h."""
|
||||
|
||||
@ -124,6 +127,7 @@ class TestSyncedCopySharesCredit:
|
||||
assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestTodayCreditCount:
|
||||
"""The log -> credit count the reset hands to the registry."""
|
||||
|
||||
@ -144,6 +148,7 @@ class TestTodayCreditCount:
|
||||
assert today_credit_count(log, _DAY) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestResetReplaysTodaysCredit:
|
||||
"""reset_to_base_if_new_day writes base + earned, capped at the ceiling."""
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _earned
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_flat_bonuses_if_new,
|
||||
@ -27,6 +28,7 @@ from screen_locker.tests._earned_fixtures import (
|
||||
EXTRA,
|
||||
answering,
|
||||
credit,
|
||||
register,
|
||||
signing_key,
|
||||
write_ledger,
|
||||
)
|
||||
@ -58,7 +60,7 @@ def answers() -> Iterator[dict[str, bool | None]]:
|
||||
@pytest.fixture
|
||||
def with_extra(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Register EXTRA after the existing earners, as a new gate would be."""
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
|
||||
register(monkeypatch, EXTRA)
|
||||
|
||||
|
||||
class TestFlatBonuses:
|
||||
@ -79,6 +81,7 @@ class TestFlatBonuses:
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
assert mixin._adjust_shutdown_time_by.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_only_reading_earned_applies_only_reading(
|
||||
self, tmp_path: Path, answers: dict[str, bool | None]
|
||||
) -> None:
|
||||
@ -138,11 +141,12 @@ class TestANewlyRegisteredEarner:
|
||||
penalty_from=cut,
|
||||
)
|
||||
before = (base_minutes(eve), base_minutes(cut))
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, penalised))
|
||||
register(monkeypatch, penalised)
|
||||
assert (base_minutes(eve), base_minutes(cut)) == (before[0], before[1] - 60)
|
||||
|
||||
|
||||
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
|
||||
@ -193,10 +197,14 @@ class TestProjectionFollowsTheCut:
|
||||
def base(day: date) -> int:
|
||||
# From the sleep ladder (Sat 2026-10-10) the registry prices the
|
||||
# day itself; before it, the cut from Tuesday holds.
|
||||
return earned_time.base_for(day, earned_time.EARNERS).shutdown_minutes
|
||||
# The registry in force that day (the tutor cutover switches it).
|
||||
return earned_time.base_for(day, _earned.registry(day)).shutdown_minutes
|
||||
|
||||
week = [date(2026, 10, 5 + i) for i in range(7)]
|
||||
assert [base(d) for d in week[1:5]] == [lowered] * 4
|
||||
assert [base(d) for d in week[1:4]] == [lowered] * 3
|
||||
# Fri 2026-10-09 the Anki waiver drops Tuesday's cuts again.
|
||||
assert week[4] == earned_time.ANKI_WAIVED_FROM
|
||||
assert base(week[4]) == 19 * 60
|
||||
assert [d.minutes for d in shutdown.rest_of_week] == [19 * 60] + [
|
||||
base(d) for d in week[1:]
|
||||
]
|
||||
|
||||
@ -53,6 +53,7 @@ def unsolved() -> Iterator[MagicMock]:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestResetIncludesTheHour:
|
||||
def test_reset_writes_base_plus_leetcode(
|
||||
self, tmp_path: Path, solved: MagicMock
|
||||
@ -86,6 +87,7 @@ class TestResetIncludesTheHour:
|
||||
mixin._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestLivePass:
|
||||
def test_applies_once_and_stamps(self, tmp_path: Path, solved: MagicMock) -> None:
|
||||
state = tmp_path / "state.json"
|
||||
|
||||
@ -104,6 +104,7 @@ class TestLegacySickDayState:
|
||||
assert self._load(tmp_path, state) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestMinutesReachTheHelperScript:
|
||||
"""The helper receives HH:MM, so a half hour is not floored on the way."""
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -154,6 +156,7 @@ class TestAdjustShutdownTimeEarlier:
|
||||
assert locker._adjust_shutdown_time_earlier() is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestAdjustShutdownTimeLater:
|
||||
"""Tests for _adjust_shutdown_time_later method."""
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import flat_earners, hhmm
|
||||
from screen_locker._earned import flat_earners, hhmm, registry
|
||||
from screen_locker._shutdown_base import (
|
||||
_apply_flat_bonus,
|
||||
apply_flat_bonuses_if_new,
|
||||
@ -69,17 +69,20 @@ class TestBaseHour:
|
||||
From earned_time's sleep ladder (2026-10-10) the floor is the ceiling
|
||||
minus every earner's first unit, not 20:00 minus the cuts, so the day
|
||||
is priced by the registry and only the "stays lowered" part is ours.
|
||||
The Anki waiver (``ANKI_WAIVED_FROM``) raises that floor to 19:50, so
|
||||
"lowered" means below the uncut 20:00, not at or below 19:00.
|
||||
"""
|
||||
later = date(2030, 1, 1)
|
||||
expected = earned_time.base_for(later, earned_time.EARNERS).shutdown_minutes
|
||||
expected = earned_time.base_for(later, registry(later)).shutdown_minutes
|
||||
assert base_minutes(later) == expected
|
||||
assert expected <= 19 * 60
|
||||
assert expected < base_minutes(date(2026, 9, 30))
|
||||
|
||||
def test_default_is_the_local_today(self) -> None:
|
||||
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
|
||||
|
||||
|
||||
class TestResetIncludesReading:
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_reset_writes_base_plus_reading_and_stamps_it(
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
@ -95,6 +98,7 @@ class TestResetIncludesReading:
|
||||
"reading_bonus_date": today_str(),
|
||||
}
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_reset_with_both_flat_hours_stamps_both(
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
@ -147,6 +151,7 @@ class TestResetIncludesReading:
|
||||
)
|
||||
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_reset_logs_every_term(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@ -167,6 +172,7 @@ class TestResetIncludesReading:
|
||||
|
||||
|
||||
class TestReadingLivePass:
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_applies_once_and_stamps(
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
@ -228,6 +234,7 @@ class TestReadingLivePass:
|
||||
mixin._adjust_shutdown_time_by.assert_not_called()
|
||||
assert not state.exists()
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
def test_applied_hour_is_logged(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
|
||||
@ -67,7 +67,7 @@ class TestTarget:
|
||||
],
|
||||
)
|
||||
with (
|
||||
patch("screen_locker._shutdown_target.flat_answers", return_value=_FLAT),
|
||||
patch("screen_locker._shutdown_target.gate_answers", return_value=_FLAT),
|
||||
patch.object(_grace_floor, "first_credit_time", return_value=None),
|
||||
):
|
||||
target = gather(_DAY, log)
|
||||
|
||||
@ -4,11 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._status_view_verify import (
|
||||
_backfill_week_and_apply_bonus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("pre_ladder")
|
||||
class TestBackfillWeekAndApplyBonus:
|
||||
"""The week-scan fallback: backfill unlogged days, apply the earned bonus."""
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user