mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 13:30:58 +01:00
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The screen locker stopped locking on 2026-08-04 and nobody could tell. Not a
logic bug: every predicate worked the whole time. systemd logged
timers.target: Found ordering cycle: early-bird-workout-check.timer/start
after graphical-session.target/start after basic.target/start after
timers.target/start - after early-bird-workout-check.timer
timers.target: Job early-bird-workout-check.timer/start deleted to break
ordering cycle starting with timers.target/start
and left the timer disabled. A timer that is WantedBy=timers.target must not be
ordered After= a target that itself comes after timers.target; systemd resolves
that loop by deleting the job, which is an absence, and absences page nobody.
That timer is the ONLY thing that closes the early-bird window. Each morning a
run inside the window banked early_bird_pending.json, logged "will re-check at
08:30", and exited 0. The re-check never came, so the marker stood all day,
every day, for thirteen days.
It stayed invisible because logging.basicConfig was called only in the
--sync-only branch, never for --production -- the mode systemd actually runs.
Journal proof: zero INFO lines from workout-locker.service since June. Every
early exit logged at INFO into a logger with no handler. Meanwhile the healthy
15-minute sync kept the system looking alive.
The 2026-08-15 Python split is NOT implicated: it was pure code motion, and
basicConfig already sat in that branch before it.
Fixes:
* Drop the After= that closes the cycle; Persistent=true so a machine asleep at
08:30 still re-checks after resume.
* Add workout-locker.timer (*:00,30). The service is WantedBy=
graphical-session.target, i.e. one decision per login, so the 08-16 -> 08-17
day and ISO-week rollover inside one long session was never re-evaluated.
* arm.sh: install units, enable timers, verify -- one command, because
re-arming was a four-command sequence that was easy to half-do, and
`systemctl enable` returns 0 while systemd deletes the timer job.
* check_enforcement_armed.py asks systemd what it actually scheduled (enabled
AND has a next trigger) and fails loudly otherwise. Wired as ExecStartPost on
workout-sync.service, so a disarmed locker marks that unit failed every 15
minutes instead of scrolling past. install_systemd.sh now fails on it too.
Logging (the reason this went unnoticed, so treated as a defect in its own
right):
* basicConfig for every mode, never one branch.
* Every enforcement-abandoning exit records a decision instead of an invisible
INFO: one greppable line -- DECISION lock=no reason=early_bird_window_active
weekly=0/5 -- at WARNING, since per CLAUDE.md an INFO explanation of a
non-action is a silent failure with extra steps. The enforced case is logged
too: "no line at all" was the outage's whole signature.
* A durable JSONL trail outside the repo (~/.local/share/screen_locker/), so
"when did this last actually enforce?" survives journal rotation. --status
and --sync-only record lock=n/a; "did not decide" must never look like
"decided not to lock".
* check_silent_failures.py now also catches sys.exit(0) that explains nothing.
Verified both directions: it flags 3/3 exits in the pre-fix _auto_upgrade.py
and passes the repo clean.
Sync noise: four records replayed as WARNINGs every cycle (~384/day), which is
how the actionable lines got tuned out. manual_workout:2026-08-15 is an empty
stub ({date, kind, type}) -- no workout to recover, and that day's credit was
already counted under a different sync_record_id, so no credit was ever lost.
The three "collided" records are dedup working. Both now report at info; a
WARNING from this logger again means something real. 4 -> 0 per cycle.
Verified on the live system: the periodic timer fired at 16:45 and the journal
shows `DECISION lock=no reason=scheduled_skip_day weekly=0/5` -- the line that
did not exist for thirteen days. Both timers report enabled and scheduled, and
no ordering cycle recurs. Today is a scheduled skip so the machine was not
locked mid-session; enforcement resumes tomorrow 08:30. 1007 tests, 100%
coverage, all hooks green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2b6ZjBcdrkSYnMujx4mWd
249 lines
8.4 KiB
Python
249 lines
8.4 KiB
Python
"""Shared fixtures and helpers for screen_locker tests.
|
|
|
|
Safety:
|
|
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` module
|
|
reference inside ``screen_lock`` with a MagicMock, replaces ``GateRoot``
|
|
with a callable returning that same mock root, and stubs ``sys.exit`` — no
|
|
test can create a real Tk root, go fullscreen, or grab input, even one
|
|
that forgets to request the explicit ``mock_tk`` fixture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from screen_locker.tests._gatelock_fixtures import (
|
|
FAKE_OUTPUTS,
|
|
TWO_OUTPUTS,
|
|
_hermetic_gatelock,
|
|
dual_output,
|
|
)
|
|
from screen_locker.tests._locker_factories import (
|
|
_make_locker,
|
|
create_locker,
|
|
create_locker_early_bird,
|
|
create_locker_relaxed_day,
|
|
)
|
|
from screen_locker.tests._opt_in_fixtures import (
|
|
_mock_sys_exit,
|
|
mock_sys_exit,
|
|
mock_tk,
|
|
temp_log_file,
|
|
)
|
|
from screen_locker.tests._tk_mocks import (
|
|
_TK_MODULES,
|
|
_VT_SHUTIL,
|
|
_VT_SUBPROCESS,
|
|
_make_mock_tk,
|
|
)
|
|
|
|
# Re-exported so pytest still resolves these by name: a fixture defined in a
|
|
# sibling module is only visible to tests once conftest imports it. __all__
|
|
# keeps ruff from pruning them as unused.
|
|
__all__ = [
|
|
"FAKE_OUTPUTS",
|
|
"TWO_OUTPUTS",
|
|
"_hermetic_gatelock",
|
|
"_make_locker",
|
|
"_mock_sys_exit",
|
|
"create_locker",
|
|
"create_locker_early_bird",
|
|
"create_locker_relaxed_day",
|
|
"dual_output",
|
|
"mock_sys_exit",
|
|
"mock_tk",
|
|
"temp_log_file",
|
|
]
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator, Iterator
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_tk_and_exit() -> Iterator[None]:
|
|
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
|
|
|
|
Patching the entire module (not just ``tk.Tk``) ensures that
|
|
**nothing** in tkinter can touch the real display server.
|
|
"""
|
|
mock = _make_mock_tk()
|
|
|
|
with ExitStack() as stack:
|
|
for module in _TK_MODULES:
|
|
stack.enter_context(patch(f"{module}.tk", mock))
|
|
stack.enter_context(
|
|
patch(
|
|
"screen_locker.screen_lock.GateRoot",
|
|
return_value=mock.Tk.return_value,
|
|
)
|
|
)
|
|
stack.enter_context(patch("screen_locker.screen_lock.sys.exit"))
|
|
# The startup early-exit checks moved to _startup_checks; sys.exit is
|
|
# bound there now, so it needs blocking separately.
|
|
stack.enter_context(patch("screen_locker._startup_checks.sys.exit"))
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_subprocess_run() -> Generator[MagicMock]:
|
|
"""Block real subprocess calls (e.g. setxkbmap) for every test.
|
|
|
|
Also exposed as a named fixture so individual tests can assert
|
|
on the calls made (e.g. VT switching tests).
|
|
|
|
``shutil.which`` is mocked to return a stable fake path so tests work
|
|
regardless of whether setxkbmap is installed on the host machine.
|
|
"""
|
|
with (
|
|
patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
|
|
patch(f"{_VT_SUBPROCESS}.run") as mock,
|
|
):
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_network() -> Iterator[None]:
|
|
"""Block real subnet probes and wttr.in calls for every test.
|
|
|
|
Otherwise phone verification / StatusWindow's temperature fetch would
|
|
reach the real network. Tests needing a real probe patch it locally.
|
|
"""
|
|
targets = (
|
|
# The subnet probes moved out of _phone_verification when it was split;
|
|
# both new homes must be blocked or a test reaches the real network.
|
|
"screen_locker._adb_transport.socket.create_connection",
|
|
"screen_locker._http_workout_fetch.socket.create_connection",
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
)
|
|
with ExitStack() as stack:
|
|
for target in targets:
|
|
stack.enter_context(patch(target, side_effect=OSError("blocked")))
|
|
yield
|
|
|
|
|
|
# Each on-disk state path, and every module that bound it by value at import
|
|
# time. All of them need patching, not just the _constants source -- a missed
|
|
# binding lets a test write to the real file.
|
|
_ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
(
|
|
"sick_history.json",
|
|
("_sick_tracker.SICK_HISTORY_FILE", "_constants.SICK_HISTORY_FILE"),
|
|
),
|
|
(
|
|
"early_bird_pending.json",
|
|
(
|
|
"_early_bird.EARLY_BIRD_PENDING_FILE",
|
|
"_constants.EARLY_BIRD_PENDING_FILE",
|
|
),
|
|
),
|
|
(
|
|
"extra_benefits_state.json",
|
|
(
|
|
"_constants.EXTRA_BENEFITS_FILE",
|
|
"_startup_checks.EXTRA_BENEFITS_FILE",
|
|
"_sync_mixin.EXTRA_BENEFITS_FILE",
|
|
"_early_bird.EXTRA_BENEFITS_FILE",
|
|
"_status.EXTRA_BENEFITS_FILE",
|
|
),
|
|
),
|
|
(
|
|
"sick_day_state.json",
|
|
(
|
|
"_constants.SICK_DAY_STATE_FILE",
|
|
"_startup_checks.SICK_DAY_STATE_FILE",
|
|
"_shutdown_sick_state.SICK_DAY_STATE_FILE",
|
|
),
|
|
),
|
|
("scheduled_skips.json", ("_log_mixin.SCHEDULED_SKIPS_FILE",)),
|
|
# The durable lock-decision trail. Written on EVERY locker run, so without
|
|
# this the suite would append test decisions to the user's real
|
|
# enforcement history in ~/.local/share/screen_locker/.
|
|
("decisions.jsonl", ("_decision_log.DECISION_LOG_FILE",)),
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect every on-disk state path to tmp_path for every test.
|
|
|
|
Stays in conftest.py and stays autouse: pytest only applies autouse
|
|
fixtures declared here, so moving this to a sibling module would leave
|
|
tests writing to the user's real state files while still passing.
|
|
"""
|
|
with ExitStack() as stack:
|
|
for filename, bindings in _ISOLATED_STATE:
|
|
target = tmp_path / filename
|
|
for binding in bindings:
|
|
stack.enter_context(patch(f"screen_locker.{binding}", target))
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect SHUTDOWN_BASE_FILE to tmp_path so tests cannot touch real state.
|
|
|
|
Pre-seeded with today's date so reset_to_base_if_new_day() is a no-op by
|
|
default (matching the real file's steady state) -- tests that want to
|
|
exercise the actual reset path patch reset_to_base_if_new_day directly,
|
|
same as the rest of the suite already does.
|
|
"""
|
|
target = tmp_path / "shutdown_base.json"
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
target.write_text(
|
|
json.dumps(
|
|
{"base_mon_wed_hour": 21, "base_thu_sun_hour": 21, "last_reset_date": today}
|
|
)
|
|
)
|
|
with (
|
|
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_weekly_logic() -> Iterator[None]:
|
|
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
|
|
|
|
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
|
|
branch instead of the full-lock path that existing tests expect.
|
|
Setting has_weekly_minimum=False ensures the full lock is shown
|
|
(weekly quota not reached → enforce).
|
|
"""
|
|
with (
|
|
patch(
|
|
"screen_locker._startup_checks.is_relaxed_day",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"screen_locker._startup_checks.has_weekly_minimum",
|
|
return_value=False,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Point the Firebase config at a path that does not exist.
|
|
|
|
``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to
|
|
build a Firebase-primary mirror. On a developer machine that file *does*
|
|
exist, so without this the sync tests would reach the real database and
|
|
assert against live data instead of their own fakes. Tests that want the
|
|
Firebase path point it back at a file they control.
|
|
"""
|
|
# CONFIG_FILE is read by remote_client, which lives in _sync_client since
|
|
# _workout_sync was split. Patching the old module would leave the real
|
|
# config in play and the sync tests would assert against live data.
|
|
from screen_locker import _sync_client
|
|
|
|
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
|