mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:07:14 +01:00
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0) and nothing else. Removed: the 7/30/90-day budget, the escalating lockout countdown, the justification form, the commitment prompt and its double debt, the workout-debt counter, the -60 min shutdown cut and its sick_day_state.json, the post-sick-day --verify-workout mode, the sick auto-upgrade, scripts/revoke_sick_day.py and their tests. - Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens; one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort background sickdays.sync_quietly -> unlock. Refusals (free day, unreadable pool) are shown and the lock stays. - Shutdown: the reset resolves with earned_time.resolve(..., sick=True) (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace lift, so later credits absorb it instead of stacking on 20:00. A day marked after the reset is lifted by the new _sick_floor live pass (every 5-min tick, --sync-only, --apply-bonuses, and the tap itself). - Workout lock and grace floor stand down on a sick day; workouts that day credit normally through the sync paths. - wake-alarm workday_penalty.json is void when its issued_date or penalty_date is a sick day. - Status window, i3 line, CLI --status, MCP flags and web reasons read sickdays. - scripts/migrate_sick_history.py imports sick_history.json's dates via sickdays.import_legacy, verifies and removes the file; idempotent, run by install.sh. - Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending); pre-commit 4.7.0, ruff 0.17.1 (freshness gate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
232 lines
8.3 KiB
Python
232 lines
8.3 KiB
Python
"""Tests for screen_locker._cli, the command-line entry-point dispatcher.
|
|
|
|
This logic used to live in ``screen_lock.py``'s ``if __name__ == "__main__":``
|
|
block, which coverage excludes -- so it shipped untested. Splitting it into a
|
|
real module for the 250-line cap made it testable; these tests cover it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, ClassVar # ClassVar is needed at runtime
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from screen_locker import _cli
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
|
|
class _FakeLocker:
|
|
"""Stand-in for ScreenLocker recording how it was constructed."""
|
|
|
|
instances: ClassVar[list[_FakeLocker]] = []
|
|
|
|
def __init__(self, *, demo_mode: bool = True) -> None:
|
|
self.demo_mode = demo_mode
|
|
self.ran = False
|
|
self.log_file: Path | None = None
|
|
self.workout_data: dict[str, str] | None = None
|
|
self.synced = False
|
|
_FakeLocker.instances.append(self)
|
|
|
|
def run(self) -> None:
|
|
"""Record that the main loop was entered."""
|
|
self.ran = True
|
|
|
|
def sync_now(self) -> None:
|
|
"""Record that a sync pass was requested."""
|
|
self.synced = True
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_instances() -> Iterator[None]:
|
|
"""Clear the recorded instances before each test."""
|
|
_FakeLocker.instances = []
|
|
yield
|
|
_FakeLocker.instances = []
|
|
|
|
|
|
class TestHeadlessLocker:
|
|
"""_headless_locker bypasses __init__ so no UI is ever built."""
|
|
|
|
def test_bypasses_init_and_seeds_only_the_two_needed_fields(self) -> None:
|
|
"""Bypassing __init__ leaves only log_file and workout_data set."""
|
|
locker = _cli._headless_locker(_FakeLocker)
|
|
|
|
# __init__ never ran, so demo_mode is absent entirely.
|
|
assert not hasattr(locker, "demo_mode")
|
|
assert not _FakeLocker.instances
|
|
assert not locker.workout_data
|
|
assert locker.log_file.name == "log.json"
|
|
|
|
def test_log_file_sits_beside_the_package(self) -> None:
|
|
"""The workout log is resolved relative to the package directory."""
|
|
locker = _cli._headless_locker(_FakeLocker)
|
|
|
|
assert locker.log_file.parent == Path(_cli.__file__).resolve().parent
|
|
|
|
|
|
class TestStatusMode:
|
|
"""--status runs the status report against a headless locker."""
|
|
|
|
def test_calls_run_status_without_constructing_a_ui(self) -> None:
|
|
"""--status reports via run_status and builds no locker."""
|
|
# The real run_status ends in sys.exit(0), which is why main() needs
|
|
# no `return` here; the fake reproduces that so the fall-through to
|
|
# the UI locker is exercised exactly as it is in production.
|
|
with (
|
|
patch.object(_cli, "run_status", side_effect=SystemExit(0)) as run_status,
|
|
pytest.raises(SystemExit) as excinfo,
|
|
):
|
|
_cli.main(_FakeLocker, ["screen_lock.py", "--status"])
|
|
|
|
assert not excinfo.value.code
|
|
run_status.assert_called_once()
|
|
passed = run_status.call_args[0][0]
|
|
assert not passed.workout_data
|
|
# No real locker was constructed for the status path.
|
|
assert not _FakeLocker.instances
|
|
|
|
|
|
class TestSyncOnlyMode:
|
|
"""--sync-only runs one headless sync pass and exits 0.
|
|
|
|
The suite-wide conftest fixture patches ``sys.exit`` to a no-op so a stray
|
|
exit cannot kill the test run, so these assert on the call rather than on
|
|
a raised SystemExit.
|
|
"""
|
|
|
|
def test_syncs_then_exits_zero(self) -> None:
|
|
"""--sync-only configures logging and exits 0."""
|
|
with (
|
|
patch.object(_cli.logging, "basicConfig") as basic_config,
|
|
patch.object(_cli.sys, "exit") as sys_exit,
|
|
):
|
|
_cli.main(_FakeLocker, ["screen_lock.py", "--sync-only"])
|
|
|
|
sys_exit.assert_called_once_with(0)
|
|
basic_config.assert_called_once()
|
|
|
|
def test_actually_calls_sync_now(self) -> None:
|
|
"""--sync-only runs exactly one sync pass."""
|
|
synced: list[bool] = []
|
|
|
|
class _Recorder(_FakeLocker):
|
|
def sync_now(self) -> None:
|
|
"""Record the sync call."""
|
|
synced.append(True)
|
|
|
|
with (
|
|
patch.object(_cli.logging, "basicConfig"),
|
|
patch.object(_cli.sys, "exit"),
|
|
):
|
|
_cli.main(_Recorder, ["screen_lock.py", "--sync-only"])
|
|
|
|
assert synced == [True]
|
|
|
|
|
|
class TestApplyBonusesMode:
|
|
"""--apply-bonuses runs only the flat-bonus pass, headless, and exits 0."""
|
|
|
|
def test_applies_flat_bonuses_then_exits_zero(self) -> None:
|
|
"""The pass gets the state file and a headless locker; nothing syncs."""
|
|
with (
|
|
patch.object(_cli.logging, "basicConfig"),
|
|
patch.object(_cli.sys, "exit") as sys_exit,
|
|
patch.object(_cli, "apply_flat_bonuses_if_new") as apply_bonuses,
|
|
patch.object(_cli, "record_no_decision") as no_decision,
|
|
):
|
|
_cli.main(_FakeLocker, ["screen_lock.py", "--apply-bonuses"])
|
|
|
|
state_file, locker = apply_bonuses.call_args.args
|
|
assert state_file == _cli.SHUTDOWN_BASE_FILE
|
|
assert isinstance(locker, _FakeLocker)
|
|
# Headless: __init__ never ran, so no UI and no lock screen.
|
|
assert not hasattr(locker, "demo_mode")
|
|
no_decision.assert_called_once_with("--apply-bonuses")
|
|
sys_exit.assert_any_call(0)
|
|
|
|
|
|
class TestLockMode:
|
|
"""With no subcommand flag, the real locker is built and run."""
|
|
|
|
def test_defaults_to_demo_mode(self) -> None:
|
|
"""With no flags the locker is built in demo mode and run."""
|
|
_cli.main(_FakeLocker, ["screen_lock.py"])
|
|
|
|
locker = _FakeLocker.instances[-1]
|
|
assert locker.demo_mode is True
|
|
assert locker.ran is True
|
|
|
|
def test_production_flag_disables_demo_mode(self) -> None:
|
|
"""--production turns demo mode off."""
|
|
_cli.main(_FakeLocker, ["screen_lock.py", "--production"])
|
|
|
|
locker = _FakeLocker.instances[-1]
|
|
assert locker.demo_mode is False
|
|
|
|
def test_verify_workout_flag_is_gone(self) -> None:
|
|
"""The post-sick-day verify mode was removed with the old sick day."""
|
|
assert "--verify-workout" not in _cli._MODE_FLAGS
|
|
assert "--verify-workout" not in _cli._USAGE
|
|
|
|
|
|
class TestScreenLockDelegation:
|
|
"""screen_lock.py's __main__ block delegates here rather than duplicating."""
|
|
|
|
def test_main_is_exported(self) -> None:
|
|
"""main is the module's public entry point."""
|
|
assert _cli.__all__ == ["main"]
|
|
assert callable(_cli.main)
|
|
|
|
|
|
class TestLogManualWorkoutMode:
|
|
"""--log-manual-workout hands everything after the flag to the logger.
|
|
|
|
Dispatch only: the field rules themselves are covered by
|
|
``test_manual_cli.py`` against the real validator.
|
|
"""
|
|
|
|
def test_forwards_the_remaining_args_and_exits_with_its_code(self) -> None:
|
|
"""The flag's own name is not passed on, and the exit code is used."""
|
|
seen: list[tuple[object, list[str]]] = []
|
|
|
|
def _fake_run(log_file: object, argv: list[str]) -> int:
|
|
"""Record the call and report a refusal."""
|
|
seen.append((log_file, list(argv)))
|
|
return 1
|
|
|
|
with (
|
|
patch.object(_cli.logging, "basicConfig"),
|
|
patch.object(_cli, "run_manual_log", _fake_run),
|
|
patch.object(_cli.sys, "exit") as sys_exit,
|
|
):
|
|
_cli.main(
|
|
_FakeLocker,
|
|
["screen_lock.py", "--log-manual-workout", "--sport", "other"],
|
|
)
|
|
|
|
(log_file, argv) = seen[0]
|
|
assert argv == ["--sport", "other"]
|
|
assert log_file.name == "log.json"
|
|
sys_exit.assert_called_once_with(1)
|
|
|
|
def test_exits_before_reaching_the_lock_screen(self) -> None:
|
|
"""A successful log exits 0 rather than falling through to the UI.
|
|
|
|
``sys.exit`` is a no-op under the suite-wide fixture, so this asserts
|
|
on the exit call: in the real process that call is what stops the
|
|
headless path from continuing on to build a lock screen.
|
|
"""
|
|
with (
|
|
patch.object(_cli.logging, "basicConfig"),
|
|
patch.object(_cli, "run_manual_log", lambda *_: 0),
|
|
patch.object(_cli.sys, "exit") as sys_exit,
|
|
):
|
|
_cli.main(_FakeLocker, ["screen_lock.py", "--log-manual-workout"])
|
|
|
|
assert sys_exit.call_args_list[0].args == (0,)
|