screen-locker/screen_locker/tests/_real_state_fixtures.py
Krzysztof kuhy Rudnicki d6e8c0973f Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.

Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.

This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.

scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.

add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.

conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.

requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.

1256 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:41 +02:00

73 lines
3.0 KiB
Python

"""Autouse fixtures whose one job is keeping tests off real user state.
Split out of ``conftest.py`` for the 250-line cap, and grouped because they
answer the same question in two different ways: what stops this suite from
reading or overwriting the developer's own files? Loaded through
``pytest_plugins`` in ``conftest.py``, so both stay autouse for every test.
The two seams are genuinely different, which is why neither subsumes the
other. ``_isolate_home`` covers paths resolved at *call* time from
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
constants at *import* time, which redirecting home afterwards cannot reach.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(autouse=True)
def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Never let the lock chain read the developer's real free-day pool.
``_isolate_home`` cannot cover this one. freedays binds its paths from
``Path.home()`` at *import* time into module constants, so redirecting
home afterwards leaves them pointing at the real
``~/.local/share/freedays``. Every writer resolves through
``resolve_paths``, so that is the seam.
Without it, a real free day on this machine would short-circuit the lock
chain in every test here -- it is the very first check in the chain --
and the failures would name early-bird and sick-day logic instead.
"""
import freedays._api
monkeypatch.setattr(
freedays._api,
"resolve_paths",
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
)
@pytest.fixture(autouse=True)
def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Point ``$HOME`` and ``Path.home()`` at tmp_path for every test.
``ISOLATED_STATE`` cannot cover this. Everything under ``~/.config`` is
resolved at *call* time by ``crdt_sync.credential_store_for``
(``Path.home() / ".config" / app / "firebase_auth.json"``), not bound to a
module-level constant, so there is no attribute to patch -- the only seam
is home itself.
What this protects is a live credential, not a scratch file. A test that
reaches ``credential_store_for("screen_locker")`` and saves would overwrite
the machine's real Firebase session with fixture values, and the damage is
invisible until the next sync fails with HTTP 401. The same redirect also
covers the sync PAT and the shared ``~/.config/crdt-sync/`` password and
OAuth secret, which are read the same way.
``Path.home()`` is patched as well as ``$HOME``: it consults ``os.environ``
on POSIX, but only via ``expanduser``, and a test that patches the
environment differently must not silently regain access to the real one.
"""
monkeypatch.setenv("HOME", str(tmp_path))
with patch.object(Path, "home", lambda: tmp_path):
yield