diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 163ff30..661b469 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.6.0#subdirectory=earned_time + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#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.6.0#subdirectory=earned_time + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#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 ec43d91..3f80db8 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.6.0#subdirectory=earned_time", + "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 9cef12b..47eb4e0 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.6.0#subdirectory=earned_time +earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#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/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index b66bd15..3362015 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -199,8 +199,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. - # The registry in force that day (the tutor cutover switches it). - return earned_time.base_for(day, _earned.registry(day)).shutdown_minutes + # The registry in force that day (the tutor cutover switches it), + # and from today on its first credits: a never-paid tutor spares + # the floor (earned_time 0.6.1). + earners = _earned.registry(day) + starts = _earned.first_credits(earners, day) + return earned_time.base_for( + day, earners, first_credits=starts + ).shutdown_minutes week = [date(2026, 10, 5 + i) for i in range(7)] assert [base(d) for d in week[1:4]] == [lowered] * 3 diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py index bf4fc65..35e0f56 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, registry +from screen_locker._earned import first_credits, gate_earners, hhmm, registry from screen_locker._shutdown_base import ( _apply_flat_bonus, apply_flat_bonuses_if_new, @@ -70,12 +70,14 @@ class TestBaseHour: 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. + "lowered" means below the uncut 20:00, not at or below 19:00; a gate + that never paid out (the tutor) spares it (earned_time 0.6.1). """ later = date(2030, 1, 1) - expected = earned_time.base_for(later, registry(later)).shutdown_minutes - assert base_minutes(later) == expected - assert expected < base_minutes(date(2026, 9, 30)) + starts = first_credits(registry(later), later) + base = earned_time.base_for(later, registry(later), first_credits=starts) + assert base_minutes(later) == base.shutdown_minutes + assert base.shutdown_minutes < base_minutes(date(2026, 9, 30)) def test_default_is_the_local_today(self) -> None: assert base_minutes() == base_minutes(datetime.now().astimezone().date()) @@ -163,7 +165,7 @@ class TestResetIncludesReading: reset_to_base_if_new_day(tmp_path / "state.json", _mixin()) base = base_minutes() later = "".join( - f" + 0h {e.label}" for e in flat_earners() if e.name not in _NAMED + f" + 0h {e.label}" for e in gate_earners() if e.name not in _NAMED ) assert ( f"Daily base reset: {hhmm(base + 60)} (base {hhmm(base)} + 0h workout + " diff --git a/screen_locker/tests/test_tutor_cutover.py b/screen_locker/tests/test_tutor_cutover.py index 66d57d6..52fc0ec 100644 --- a/screen_locker/tests/test_tutor_cutover.py +++ b/screen_locker/tests/test_tutor_cutover.py @@ -1,9 +1,10 @@ """The Automation tutor on a simulated cutover: penalty start and grace floor. -``earned_time.TUTOR_FROM`` is a far-future sentinel, so the cutover is -simulated on a fixed day: the registry's cutover (bound by value in -``_registry`` and ``_ladder``) and the tutor's own ``penalty_from`` move to -:data:`_CUTOVER`. Nothing is written outside the suite's redirected home. +``earned_time.TUTOR_FROM`` is 2026-10-10 since earned_time 0.6.1; the +fixture still pins the registry's cutover (bound by value in ``_registry`` and +``_ladder``) and the tutor's own ``penalty_from`` to :data:`_CUTOVER`, so the +suite does not move if a later release moves the cutover. Nothing is written +outside the suite's redirected home. """ from __future__ import annotations @@ -92,13 +93,25 @@ class TestNeverCredited: assert wired.resolution.base.gaming_minutes == ( unwired.resolution.base.gaming_minutes + cut ) - # KNOWN GAP (earned_time 0.6.0): on the sleep ladder the shutdown - # floor is the ceiling minus every registered earner's full pay and - # never asks whether a penalty is in force, so a never-credited tutor - # still costs its shutdown minutes. A fix there flips this assertion. + + def test_no_tutor_rung_off_the_shutdown_floor(self) -> None: + """earned_time 0.6.1: a gate whose penalty has not started spares the floor. + + Unwired, the tutor's penalty starts at ``penalty_from`` and its full + rung (13+13+12+12) comes off the ladder floor; wired, the never-paid + tutor costs no shutdown either, so the floor stays at 19:50. + """ + answers = {e.name: 0 for e in registry(_CUTOVER)} + starts = first_credits(registry(_CUTOVER), _CUTOVER) + wired = derive(DayInputs(_CUTOVER, answers, first_credits=starts)) + unwired = derive(DayInputs(_CUTOVER, answers)) + tutor = earned_time.AUTOMATION_TUTOR + rung = tutor.shutdown_for(tutor.max_units or 1, _CUTOVER) + assert rung == 50 assert wired.resolution.base.shutdown_minutes == ( - unwired.resolution.base.shutdown_minutes + unwired.resolution.base.shutdown_minutes + rung ) + assert wired.resolution.base.shutdown_minutes == 19 * 60 + 50 @pytest.mark.usefixtures("tutor")