From bbc14ec04c70e79ffad15b046d2aca0a56261b7d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 9 Oct 2026 21:36:53 +0200 Subject: [PATCH] earned_time 0.6.0: per-day registry consumer, sleep-ladder date-proof tests, pin v0.6.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- .pre-commit-config.yaml | 4 +- pyproject.toml | 2 +- requirements.txt | 2 +- screen_locker/_earned.py | 75 +++++- screen_locker/_earner_units.py | 13 +- screen_locker/_gate_bonus.py | 101 ++++++++ screen_locker/_shutdown_base.py | 43 ++-- screen_locker/_shutdown_target.py | 15 +- screen_locker/tests/_earned_fixtures.py | 17 +- screen_locker/tests/_real_state_fixtures.py | 19 ++ screen_locker/tests/test_credit_slot.py | 5 +- screen_locker/tests/test_earned.py | 10 +- screen_locker/tests/test_earner_units.py | 9 +- screen_locker/tests/test_gate_bonus.py | 237 ++++++++++++++++++ screen_locker/tests/test_grace_floor.py | 2 +- .../tests/test_init_and_log_part2.py | 1 + .../test_manual_workout_features_part2.py | 3 + .../tests/test_runnerup_backfill_credit.py | 3 + .../tests/test_screen_lock_coverage_part2.py | 3 + .../tests/test_shutdown_base_replay.py | 5 + .../tests/test_shutdown_flat_earners.py | 16 +- screen_locker/tests/test_shutdown_leetcode.py | 2 + screen_locker/tests/test_shutdown_minutes.py | 1 + screen_locker/tests/test_shutdown_part2.py | 3 + screen_locker/tests/test_shutdown_reading.py | 13 +- screen_locker/tests/test_shutdown_target.py | 2 +- screen_locker/tests/test_status_view_part4.py | 3 + 27 files changed, 546 insertions(+), 63 deletions(-) create mode 100644 screen_locker/_gate_bonus.py create mode 100644 screen_locker/tests/test_gate_bonus.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 58926b7..2e3a32d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d019462..db705f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 412bb13..82d33fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/screen_locker/_earned.py b/screen_locker/_earned.py index 06cefb7..48b5dc2 100644 --- a/screen_locker/_earned.py +++ b/screen_locker/_earned.py @@ -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. diff --git a/screen_locker/_earner_units.py b/screen_locker/_earner_units.py index ba248fa..740da97 100644 --- a/screen_locker/_earner_units.py +++ b/screen_locker/_earner_units.py @@ -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 diff --git a/screen_locker/_gate_bonus.py b/screen_locker/_gate_bonus.py new file mode 100644 index 0000000..4c1e8b5 --- /dev/null +++ b/screen_locker/_gate_bonus.py @@ -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 +``_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 ``_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 diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index e35fa32..039ed07 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -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 (``_bonus_date`` for a flat +earner, ``_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)) diff --git a/screen_locker/_shutdown_target.py b/screen_locker/_shutdown_target.py index b7f7ecd..e18b319 100644 --- a/screen_locker/_shutdown_target.py +++ b/screen_locker/_shutdown_target.py @@ -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), diff --git a/screen_locker/tests/_earned_fixtures.py b/screen_locker/tests/_earned_fixtures.py index 6258dc0..ea265bf 100644 --- a/screen_locker/tests/_earned_fixtures.py +++ b/screen_locker/tests/_earned_fixtures.py @@ -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.""" diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index 1b94627..f3cb21c 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -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) diff --git a/screen_locker/tests/test_credit_slot.py b/screen_locker/tests/test_credit_slot.py index 772629f..3d448b4 100644 --- a/screen_locker/tests/test_credit_slot.py +++ b/screen_locker/tests/test_credit_slot.py @@ -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.""" diff --git a/screen_locker/tests/test_earned.py b/screen_locker/tests/test_earned.py index 0b16aef..351c301 100644 --- a/screen_locker/tests/test_earned.py +++ b/screen_locker/tests/test_earned.py @@ -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: diff --git a/screen_locker/tests/test_earner_units.py b/screen_locker/tests/test_earner_units.py index eec5cdb..48b1eed 100644 --- a/screen_locker/tests/test_earner_units.py +++ b/screen_locker/tests/test_earner_units.py @@ -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: diff --git a/screen_locker/tests/test_gate_bonus.py b/screen_locker/tests/test_gate_bonus.py new file mode 100644 index 0000000..5b06a5e --- /dev/null +++ b/screen_locker/tests/test_gate_bonus.py @@ -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} diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index ab83e75..6ac43d1 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -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", diff --git a/screen_locker/tests/test_init_and_log_part2.py b/screen_locker/tests/test_init_and_log_part2.py index 0708bcd..ac400ed 100644 --- a/screen_locker/tests/test_init_and_log_part2.py +++ b/screen_locker/tests/test_init_and_log_part2.py @@ -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.""" diff --git a/screen_locker/tests/test_manual_workout_features_part2.py b/screen_locker/tests/test_manual_workout_features_part2.py index a314e52..db12f08 100644 --- a/screen_locker/tests/test_manual_workout_features_part2.py +++ b/screen_locker/tests/test_manual_workout_features_part2.py @@ -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. diff --git a/screen_locker/tests/test_runnerup_backfill_credit.py b/screen_locker/tests/test_runnerup_backfill_credit.py index 87a70e2..3095a0b 100644 --- a/screen_locker/tests/test_runnerup_backfill_credit.py +++ b/screen_locker/tests/test_runnerup_backfill_credit.py @@ -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).""" diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 59db1bc..89585c5 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -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.""" diff --git a/screen_locker/tests/test_shutdown_base_replay.py b/screen_locker/tests/test_shutdown_base_replay.py index 5891762..5c16b55 100644 --- a/screen_locker/tests/test_shutdown_base_replay.py +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -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.""" diff --git a/screen_locker/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index 881288b..dd144b6 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -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:] ] diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py index ed22dab..21c05c2 100644 --- a/screen_locker/tests/test_shutdown_leetcode.py +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -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" diff --git a/screen_locker/tests/test_shutdown_minutes.py b/screen_locker/tests/test_shutdown_minutes.py index 3bbc5e3..e40cff6 100644 --- a/screen_locker/tests/test_shutdown_minutes.py +++ b/screen_locker/tests/test_shutdown_minutes.py @@ -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.""" diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index 8725618..498c2e4 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -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.""" diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py index 3ac43d3..bf4fc65 100644 --- a/screen_locker/tests/test_shutdown_reading.py +++ b/screen_locker/tests/test_shutdown_reading.py @@ -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, diff --git a/screen_locker/tests/test_shutdown_target.py b/screen_locker/tests/test_shutdown_target.py index acbbf34..e1580c4 100644 --- a/screen_locker/tests/test_shutdown_target.py +++ b/screen_locker/tests/test_shutdown_target.py @@ -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) diff --git a/screen_locker/tests/test_status_view_part4.py b/screen_locker/tests/test_status_view_part4.py index 84bc2aa..78a3a7e 100644 --- a/screen_locker/tests/test_status_view_part4.py +++ b/screen_locker/tests/test_status_view_part4.py @@ -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."""