screen-locker/screen_locker/tests/test_compliance_state.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

245 lines
9.3 KiB
Python

"""Tests for the pure, read-only predicates in _compliance_state.py."""
from __future__ import annotations
from datetime import UTC, date, datetime
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
import freedays
from screen_locker._compliance_state import (
has_logged_today,
is_early_bird_pending,
is_scheduled_skip_today,
is_sick_day_today,
)
from screen_locker._sick_tracker import SickHistory
if TYPE_CHECKING:
from pathlib import Path
def _today() -> str:
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
class TestIsScheduledSkipToday:
"""Mirrors test_scheduled_skip.py's coverage, for the standalone function."""
def test_empty_pool(self, tmp_path: Path) -> None:
"""An empty pool means today is not a free day."""
assert is_scheduled_skip_today() is False
def test_today_marked(self, tmp_path: Path) -> None:
"""Today in the shared pool makes it a free day."""
freedays.mark(
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
)
assert is_scheduled_skip_today() is True
def test_today_not_marked(self, tmp_path: Path) -> None:
"""A pool holding some other date is not today's free day."""
freedays.mark(
date(2026, 12, 24),
paths=freedays.Paths.under(tmp_path / "freedays"),
)
assert is_scheduled_skip_today() is False
def test_corrupt_pool(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: not a free day, the chain continues."""
pool = tmp_path / "freedays"
pool.mkdir(exist_ok=True)
(pool / "free_days.json").write_text("{bad}")
assert is_scheduled_skip_today() is False
def test_explicit_day_override(self, tmp_path: Path) -> None:
"""The `today` argument overrides the real date."""
freedays.mark(
date(2026, 12, 24),
paths=freedays.Paths.under(tmp_path / "freedays"),
)
assert is_scheduled_skip_today(date(2026, 12, 24)) is True
class TestHasLoggedToday:
"""Mirrors test_init_and_log.py's HMAC branches, for the standalone function."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing log means nothing was logged today."""
assert has_logged_today(tmp_path / "log.json") is False
def test_corrupt_json(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: nothing logged."""
log_file = tmp_path / "log.json"
log_file.write_text("{bad}")
assert has_logged_today(log_file) is False
def test_no_entry_for_today(self, tmp_path: Path) -> None:
"""An entry for another day does not count as today's."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({"1999-01-01": {}}))
assert has_logged_today(log_file) is False
def test_valid_hmac(self, tmp_path: Path) -> None:
"""A correctly signed entry for today counts."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {"hmac": "sig"}}))
with patch(
"screen_locker._compliance_predicates.verify_entry_hmac", return_value=True
):
assert has_logged_today(log_file) is True
def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None:
"""With no HMAC key on the machine, an unsigned entry is accepted."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {}}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value=None,
),
):
assert has_logged_today(log_file) is True
def test_rejected_when_key_available(self, tmp_path: Path) -> None:
"""With a key available, an unsigned entry is rejected as tampering."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {}}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value="sig",
),
):
assert has_logged_today(log_file) is False
def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None:
"""A signature that does not verify is rejected."""
log_file = tmp_path / "log.json"
log_file.write_text(json.dumps({_today(): {"hmac": "tampered"}}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value=None,
),
):
assert has_logged_today(log_file) is False
class TestIsEarlyBirdPending:
"""Mirrors test_early_bird.py's HMAC branches, for the standalone function."""
def test_missing_file(self, tmp_path: Path) -> None:
"""A missing pending file means no early bird is pending."""
assert is_early_bird_pending(tmp_path / "pending.json") is False
def test_corrupt_json(self, tmp_path: Path) -> None:
"""Unparsable JSON fails closed: nothing pending."""
pending_file = tmp_path / "pending.json"
pending_file.write_text("{bad}")
assert is_early_bird_pending(pending_file) is False
def test_not_a_dict(self, tmp_path: Path) -> None:
"""A JSON list where a dict is expected fails closed."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps(["not", "a", "dict"]))
assert is_early_bird_pending(pending_file) is False
def test_stale_date(self, tmp_path: Path) -> None:
"""A pending record from an earlier day no longer applies."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": "2000-01-01", "hmac": "sig"}))
assert is_early_bird_pending(pending_file) is False
def test_valid_hmac(self, tmp_path: Path) -> None:
"""A correctly signed pending record for today counts."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today(), "hmac": "sig"}))
with patch(
"screen_locker._compliance_predicates.verify_entry_hmac", return_value=True
):
assert is_early_bird_pending(pending_file) is True
def test_unsigned_accepted_when_key_unavailable(self, tmp_path: Path) -> None:
"""With no HMAC key, an unsigned pending record is accepted."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today()}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value=None,
),
):
assert is_early_bird_pending(pending_file) is True
def test_rejected_when_key_available(self, tmp_path: Path) -> None:
"""With a key available, an unsigned pending record is rejected."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today()}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value="sig",
),
):
assert is_early_bird_pending(pending_file) is False
def test_rejected_when_signed_but_invalid(self, tmp_path: Path) -> None:
"""A pending record whose signature fails to verify is rejected."""
pending_file = tmp_path / "pending.json"
pending_file.write_text(json.dumps({"date": _today(), "hmac": "bad"}))
with (
patch(
"screen_locker._compliance_predicates.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker._compliance_predicates.compute_entry_hmac",
return_value=None,
),
):
assert is_early_bird_pending(pending_file) is False
class TestIsSickDayToday:
"""Reading today's sick-day status out of the sick history."""
"""Thin wrapper around _sick_tracker.is_sick_day."""
def test_true_when_listed(self) -> None:
"""Today present in the history is a sick day."""
history = SickHistory(sick_days=[_today()])
assert is_sick_day_today(history) is True
def test_false_when_not_listed(self) -> None:
"""Today absent from the history is not a sick day."""
history = SickHistory(sick_days=["1999-01-01"])
assert is_sick_day_today(history) is False
def test_explicit_today_override(self) -> None:
"""The `today` argument overrides the real date."""
history = SickHistory(sick_days=["2020-05-05"])
assert is_sick_day_today(history, today="2020-05-05") is True