screen-locker/screen_locker/tests/test_shutdown_part4.py
Krzysztof kuhy Rudnicki aa2776a45e Cap live bonuses at the day's ceiling
The by-minutes bonus path clamped at 24:00 (_MIDNIGHT) and relied on
adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00). It now clamps at
earned_time.shutdown_ceiling_for(day) via the new _ceiling.ceiling, so a
moved WAKE_MINUTES cannot be passed by a bonus. The shell's 1380 stays
the hard upper bound; ceiling() is min()'d with it, and a test pins the
Python constant to the script's value. _preview_sim and the invariant
test reuse that constant instead of a second 23 * 60.

Failure mode: if the earned_time lookup raises (ArithmeticError,
LookupError, TypeError, ValueError), ceiling() falls back to 23:00
loudly (logger.warning), never silently and never above the helper's
bound. That is fail-open relative to a lowered ceiling, bounded by the
shell clamp; an earned_time without the API (0.3.0) uses its constant.

Red first: new tests (21:00 ceiling, failing lookup, caps-at-ceiling)
failed on c3a5d14, pass now. 14-day replay of the real ledgers
(2026-10-18..31) through old and new code, plus an all-done-at-22:30
counterfactual: identical, every day 23:00; live files unchanged.

TODO-cap-bonus-at-day-ceiling.md removed (done).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:04:14 +01:00

163 lines
5.5 KiB
Python

"""Tests for sick-day state save/load and config restoration."""
from __future__ import annotations
import logging
from pathlib import Path
import re
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import earned_time
from screen_locker._ceiling import HARD_CEILING_MINUTES
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
import pytest
def test_hard_ceiling_matches_the_helpers_restore_ceiling() -> None:
"""The Python bound and adjust_shutdown_schedule.sh's must not drift."""
script = Path(__file__).parents[1] / "adjust_shutdown_schedule.sh"
match = re.search(
r"^readonly RESTORE_CEILING=(\d+)", script.read_text(), re.MULTILINE
)
assert match is not None
assert int(match.group(1)) == HARD_CEILING_MINUTES
class TestAdjustShutdownTimeBy:
"""Tests for _adjust_shutdown_time_by method (extra-workout bonus)."""
def test_adjusts_time_successfully(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Normal path: reads config, increments both hours, writes back."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(1260, 1260, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
assert locker._adjust_shutdown_time_by(60) is True
locker._write_shutdown_config.assert_called_once_with(
1320, 1320, 300, restore=True
)
def test_caps_at_the_day_ceiling(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Times are capped at the day's ceiling (23:00 today), not midnight."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
locker._adjust_shutdown_time_by(120)
locker._write_shutdown_config.assert_called_once_with(
1380, 1380, 300, restore=True
)
def test_caps_at_a_ceiling_below_23(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If WAKE_MINUTES moves the ceiling to 21:00 no bonus passes it."""
monkeypatch.setattr(earned_time, "shutdown_ceiling_for", lambda _d: 21 * 60)
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(1200, 1200, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
locker._adjust_shutdown_time_by(120)
locker._write_shutdown_config.assert_called_once_with(
1260, 1260, 300, restore=True
)
def test_failed_ceiling_lookup_falls_back_to_23_loudly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""earned_time raising must not lift the cap: 23:00, logged at warning."""
def boom(_day: object) -> int:
msg = "registry unreadable"
raise ValueError(msg)
monkeypatch.setattr(earned_time, "shutdown_ceiling_for", boom)
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
with caplog.at_level(logging.WARNING):
locker._adjust_shutdown_time_by(120)
locker._write_shutdown_config.assert_called_once_with(
1380, 1380, 300, restore=True
)
assert "ceiling" in caplog.text
def test_returns_false_when_config_is_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_read_shutdown_config returns None → return False immediately."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
assert locker._adjust_shutdown_time_by(1) is False
def test_returns_false_on_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError during read is caught; returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=OSError("permission denied")),
)
assert locker._adjust_shutdown_time_by(1) is False
def test_returns_false_on_value_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""ValueError during processing is caught; returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=ValueError("bad value")),
)
assert locker._adjust_shutdown_time_by(1) is False