mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
8.6 KiB
Python
227 lines
8.6 KiB
Python
"""Tests for the reading hour as a term of the shutdown derivation.
|
|
|
|
Covers the base cut it pays for (20 -> 19 from 2026-10-01), the daily reset
|
|
folding the hour in and stamping it, and the live pass that adds it once for a
|
|
reading credit landing after the reset. Mirrors test_shutdown_leetcode.py; the
|
|
pass over every flat earner is in test_shutdown_flat_earners.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import earned_time
|
|
import pytest
|
|
|
|
from screen_locker import _shutdown_base
|
|
from screen_locker._day import today_str
|
|
from screen_locker._earned import hhmm
|
|
from screen_locker._shutdown_base import (
|
|
_apply_flat_bonus,
|
|
apply_flat_bonuses_if_new,
|
|
base_minutes,
|
|
reset_to_base_if_new_day,
|
|
)
|
|
from screen_locker.tests._earned_fixtures import answering
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
def _mixin(*, adjust_ok: bool = True) -> 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 = adjust_ok
|
|
return mixin
|
|
|
|
|
|
def apply_reading_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
|
|
"""The live pass for the reading earner alone."""
|
|
return _apply_flat_bonus(state, mixin, earned_time.READING)
|
|
|
|
|
|
@pytest.fixture
|
|
def answers() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
|
|
"""(answers, earned_today mock): every flat earner says no until set."""
|
|
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
|
|
with answering(given) as mock:
|
|
yield given, mock
|
|
|
|
|
|
class TestBaseHour:
|
|
def test_day_before_the_cut_keeps_twenty(self) -> None:
|
|
assert base_minutes(date(2026, 9, 30)) == 20 * 60
|
|
|
|
def test_cut_day_is_nineteen(self) -> None:
|
|
assert earned_time.READING.penalty_from == date(2026, 10, 1)
|
|
assert base_minutes(date(2026, 10, 1)) == 19 * 60
|
|
|
|
def test_long_after_the_cut_stays_nineteen(self) -> None:
|
|
assert base_minutes(date(2030, 1, 1)) == 19 * 60
|
|
|
|
def test_default_is_the_local_today(self) -> None:
|
|
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
|
|
|
|
|
|
class TestResetIncludesReading:
|
|
def test_reset_writes_base_plus_reading_and_stamps_it(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
answers[0]["reading"] = True
|
|
state = tmp_path / "state.json"
|
|
mixin = _mixin()
|
|
assert reset_to_base_if_new_day(state, mixin) is True
|
|
mixin._write_shutdown_config.assert_called_once_with(
|
|
base_minutes() + 60, base_minutes() + 60, 300, restore=True
|
|
)
|
|
assert json.loads(state.read_text()) == {
|
|
"last_reset_date": today_str(),
|
|
"reading_bonus_date": today_str(),
|
|
}
|
|
|
|
def test_reset_with_both_flat_hours_stamps_both(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
answers[0]["leetcode"] = True
|
|
answers[0]["reading"] = True
|
|
state = tmp_path / "state.json"
|
|
mixin = _mixin()
|
|
assert reset_to_base_if_new_day(state, mixin) is True
|
|
mixin._write_shutdown_config.assert_called_once_with(
|
|
base_minutes() + 120, base_minutes() + 120, 300, restore=True
|
|
)
|
|
assert json.loads(state.read_text()) == {
|
|
"last_reset_date": today_str(),
|
|
"leetcode_bonus_date": today_str(),
|
|
"reading_bonus_date": today_str(),
|
|
}
|
|
apply_flat_bonuses_if_new(state, mixin)
|
|
mixin._adjust_shutdown_time_by.assert_not_called()
|
|
|
|
def test_reset_is_still_capped_at_the_ceiling(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
answers[0]["leetcode"] = True
|
|
answers[0]["reading"] = True
|
|
mixin = _mixin()
|
|
with patch.object(_shutdown_base, "today_credit_count", return_value=9):
|
|
reset_to_base_if_new_day(
|
|
tmp_path / "state.json", mixin, log_file=tmp_path / "log.json"
|
|
)
|
|
mixin._write_shutdown_config.assert_called_once_with(
|
|
1380, 1380, 300, restore=True
|
|
)
|
|
|
|
def test_unknown_reading_earns_nothing_warns_and_is_not_stamped(
|
|
self,
|
|
tmp_path: Path,
|
|
answers: tuple[dict[str, bool | None], MagicMock],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The live pass must ask again: a stamp would forfeit the day's hour."""
|
|
answers[0]["reading"] = None
|
|
state = tmp_path / "state.json"
|
|
mixin = _mixin()
|
|
with caplog.at_level("WARNING"):
|
|
assert reset_to_base_if_new_day(state, mixin) is True
|
|
assert "reading state could not be checked" in caplog.text
|
|
base = base_minutes()
|
|
mixin._write_shutdown_config.assert_called_once_with(
|
|
base, base, 300, restore=True
|
|
)
|
|
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
|
|
|
|
def test_reset_logs_every_term(
|
|
self,
|
|
tmp_path: Path,
|
|
answers: tuple[dict[str, bool | None], MagicMock],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
answers[0]["reading"] = True
|
|
with caplog.at_level("INFO"):
|
|
reset_to_base_if_new_day(tmp_path / "state.json", _mixin())
|
|
base = base_minutes()
|
|
assert (
|
|
f"Daily base reset: {hhmm(base + 60)} (base {hhmm(base)} + 0h workout + "
|
|
"0h LeetCode + 1h reading already earned today)."
|
|
) in caplog.text
|
|
|
|
|
|
class TestReadingLivePass:
|
|
def test_applies_once_and_stamps(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
answers[0]["reading"] = True
|
|
state = tmp_path / "state.json"
|
|
state.write_text(json.dumps({"last_reset_date": today_str()}))
|
|
mixin = _mixin()
|
|
assert apply_reading_bonus_if_new(state, mixin) is True
|
|
assert apply_reading_bonus_if_new(state, mixin) is False
|
|
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
|
|
assert json.loads(state.read_text()) == {
|
|
"last_reset_date": today_str(),
|
|
"reading_bonus_date": today_str(),
|
|
}
|
|
|
|
def test_stamped_today_never_reads_the_ledger(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
state = tmp_path / "state.json"
|
|
state.write_text(json.dumps({"reading_bonus_date": today_str()}))
|
|
assert apply_reading_bonus_if_new(state, _mixin()) is False
|
|
answers[1].assert_not_called()
|
|
|
|
def test_no_reading_no_write(
|
|
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
|
) -> None:
|
|
mixin = _mixin()
|
|
assert apply_reading_bonus_if_new(tmp_path / "state.json", mixin) is False
|
|
mixin._adjust_shutdown_time_by.assert_not_called()
|
|
|
|
def test_failed_write_leaves_no_stamp_and_warns(
|
|
self,
|
|
tmp_path: Path,
|
|
answers: tuple[dict[str, bool | None], MagicMock],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A stamp without a write would silently forfeit the hour for the day."""
|
|
answers[0]["reading"] = True
|
|
state = tmp_path / "state.json"
|
|
mixin = _mixin(adjust_ok=False)
|
|
with caplog.at_level("WARNING"):
|
|
assert apply_reading_bonus_if_new(state, mixin) is False
|
|
assert "reading bonus: failed to write" in caplog.text
|
|
assert not state.exists()
|
|
|
|
def test_cannot_check_warns_and_leaves_no_stamp(
|
|
self,
|
|
tmp_path: Path,
|
|
answers: tuple[dict[str, bool | None], MagicMock],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Unknown is not "no": logged, no hour, and re-asked on the next tick."""
|
|
answers[0]["reading"] = None
|
|
state = tmp_path / "state.json"
|
|
mixin = _mixin()
|
|
with caplog.at_level("WARNING"):
|
|
assert apply_reading_bonus_if_new(state, mixin) is False
|
|
assert "reading state could not be checked" in caplog.text
|
|
mixin._adjust_shutdown_time_by.assert_not_called()
|
|
assert not state.exists()
|
|
|
|
def test_applied_hour_is_logged(
|
|
self,
|
|
tmp_path: Path,
|
|
answers: tuple[dict[str, bool | None], MagicMock],
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
answers[0]["reading"] = True
|
|
with caplog.at_level("INFO"):
|
|
assert apply_reading_bonus_if_new(tmp_path / "state.json", _mixin())
|
|
assert "reading bonus: +1h shutdown time today." in caplog.text
|