"""The Automation tutor on a simulated cutover: penalty start and grace floor. ``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 from dataclasses import replace from datetime import date, datetime, time from typing import TYPE_CHECKING, Any from unittest.mock import patch import earned_time import pytest from screen_locker import _earned from screen_locker._earned import first_credits, registry from screen_locker._grace_floor import first_done_at from screen_locker._shutdown_target import DayInputs, derive from screen_locker.tests._earned_fixtures import signed, signing_key, write_ledger if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path _CUTOVER = date(2026, 10, 10) @pytest.fixture def tutor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Any]: """The tutor registry from ``_CUTOVER``, its ledger signed, today pinned. Args: monkeypatch: Moves the cutover. tmp_path: Where the key is written. Yields: The tutor earner, penalised from ``_CUTOVER``. """ item = replace(earned_time.AUTOMATION_TUTOR, penalty_from=_CUTOVER) swapped = tuple( item if e is earned_time.AUTOMATION_TUTOR else e for e in earned_time.TUTOR_EARNERS ) monkeypatch.setattr(earned_time, "TUTOR_EARNERS", swapped) monkeypatch.setattr("earned_time._registry.TUTOR_FROM", _CUTOVER) monkeypatch.setattr("earned_time._ladder.TUTOR_FROM", _CUTOVER) with ( signing_key(tmp_path), patch.object(_earned, "today_str", return_value=_CUTOVER.isoformat()), ): yield item def _block(hhmm: str) -> dict[str, Any]: hours, minutes = map(int, hhmm.split(":")) ended = datetime.combine(_CUTOVER, time(hours, minutes)).astimezone() return signed( { "kind": "credit", "entry_id": f"s-b{hhmm}", "day": _CUTOVER.isoformat(), "detail": {"block": 1, "ended_at": ended.timestamp()}, } ) def test_the_simulated_cutover_registers_the_tutor(tutor: Any) -> None: assert tutor in registry(_CUTOVER) assert tutor in _earned.gate_earners(_CUTOVER) assert tutor not in _earned.flat_earners(_CUTOVER) @pytest.mark.usefixtures("tutor") class TestConfirmedButNeverCredited: """earned_time 0.7.0: a confirmed tutor is penalised from its first day.""" def test_the_map_says_never(self) -> None: starts = first_credits(registry(_CUTOVER), _CUTOVER) assert starts is not None assert starts["automation"] is None def test_the_tutor_penalty_is_on_the_gaming_base_unpaid(self) -> None: 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)) assert wired.resolution.base.gaming_minutes == ( unwired.resolution.base.gaming_minutes ) def test_the_whole_tutor_rung_comes_off_the_shutdown_floor(self) -> None: """Confirmed on 2026-10-09, so a never-paid tutor still costs its rung. Its full rung (4 blocks of 15) comes off the ladder floor whether or not ``first_credits`` is wired, leaving 18: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 assert tutor.shutdown_for(tutor.max_units or 1, _CUTOVER) == 60 assert wired.resolution.base.shutdown_minutes == ( unwired.resolution.base.shutdown_minutes ) assert wired.resolution.base.shutdown_minutes == 18 * 60 + 50 @pytest.mark.usefixtures("tutor") class TestTheGraceFloor: """A tutor block is a first task like any other.""" def test_a_tutor_block_starts_the_floor(self, tutor: Any) -> None: write_ledger(tutor, [_block("18:55"), _block("19:10")]) expected = datetime.combine(_CUTOVER, time(18, 55)).astimezone() assert first_done_at(_CUTOVER, None) == expected.timestamp() def test_no_block_no_floor(self, tutor: Any) -> None: write_ledger(tutor, []) assert first_done_at(_CUTOVER, None) is None