mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
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
163 lines
5.5 KiB
Python
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
|