From 43a8b1d0f62de3c32cdc051a8dfbdade7207e660 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:01:12 +0200 Subject: [PATCH] Split _compliance_state.py and status_view.py _compliance_state.py was 399 lines and status_view.py 396. _compliance_state.py becomes three acyclic modules: _compliance_predicates.py the five leaf predicates + the gatelock log_integrity imports _compliance_trace.py PredicateResult / AutoUpgradeOpportunity / LockExplanation, _stage, and describe_auto_upgrade_opportunity _compliance_state.py explain_lock_decision, importing both Two-module shapes were all cyclic here: _compliance_state must export the engine while owning the predicates it calls. Three modules break that with no deferred import, no forwarder and no loss of the typed signature; every name is re-exported, so _status_data.py and _log_mixin.py are untouched. Because the hmac helpers now live in _compliance_predicates, 29 patch targets across 5 test modules move with them -- patching a name where it is no longer read would silently stop taking effect. status_view.py's seven _section_* render methods become StatusSectionsMixin in _status_sections.py, composed into StatusWindow alongside its existing mixins. Test count goes 975 -> 976: test_no_popup_widgets parametrizes over PACKAGE_DIR.glob("*.py"), so _status_sections.py is now covered by the no-popup safety check too. Coverage stays 100%, pylint holds at 8.70/10, and `screen_lock --status` renders the real week unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- screen_locker/_compliance_predicates.py | 131 +++++++++++ screen_locker/_compliance_state.py | 220 ++---------------- screen_locker/_compliance_trace.py | 102 ++++++++ screen_locker/_status_sections.py | 177 ++++++++++++++ screen_locker/status_view.py | 154 +----------- screen_locker/tests/test_compliance_state.py | 40 ++-- .../tests/test_compliance_state_part2.py | 6 +- screen_locker/tests/test_early_bird_part3.py | 15 +- screen_locker/tests/test_init_and_log.py | 12 +- screen_locker/tests/test_status_data_part2.py | 3 +- 10 files changed, 483 insertions(+), 377 deletions(-) create mode 100644 screen_locker/_compliance_predicates.py create mode 100644 screen_locker/_compliance_trace.py create mode 100644 screen_locker/_status_sections.py diff --git a/screen_locker/_compliance_predicates.py b/screen_locker/_compliance_predicates.py new file mode 100644 index 0000000..1a0b9d5 --- /dev/null +++ b/screen_locker/_compliance_predicates.py @@ -0,0 +1,131 @@ +"""Pure, read-only leaf predicates for the lock-decision chain. + +Split out of :mod:`screen_locker._compliance_state` to keep every file under +the 250-line cap. That module re-exports everything here, so existing +importers are unaffected. + +Nothing in this module touches ADB, sudo, subprocess, or the network, and +nothing writes to disk. + +Note for tests: the ``gatelock.log_integrity`` helpers are imported *here* +now, so patch them at ``screen_locker._compliance_predicates.verify_entry_hmac`` +rather than at the old ``_compliance_state`` path. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +from typing import TYPE_CHECKING + +from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac + +from screen_locker._constants import ( + EARLY_BIRD_END_HOUR, + EARLY_BIRD_END_MINUTE, + EARLY_BIRD_START_HOUR, +) +from screen_locker._log_io import load_workout_log +from screen_locker._sick_tracker import is_sick_day as _is_sick_day + +if TYPE_CHECKING: + from pathlib import Path + + from screen_locker._sick_tracker import SickHistory + +_logger = logging.getLogger(__name__) + + +def _today_str() -> str: + """Return today's date as ``YYYY-MM-DD`` in UTC.""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +def is_scheduled_skip_today( + scheduled_skips_file: Path, *, today: str | None = None +) -> bool: + """Return True if *today* is listed in *scheduled_skips_file*.""" + if not scheduled_skips_file.exists(): + return False + try: + with scheduled_skips_file.open() as f: + skips = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + _logger.warning( + "Could not read scheduled skips from %s: %s — treating today as NOT " + "a scheduled skip (the lock chain continues)", + scheduled_skips_file, + exc, + ) + return False + return (today or _today_str()) in skips + + +def has_logged_today(log_file: Path, *, today: str | None = None) -> bool: + """Return True if a validly-signed workout is logged for *today*. + + The day may hold multiple workouts; return True if ANY of today's entries + verifies (or is acceptably unsigned when no HMAC key is configured). + """ + entries = load_workout_log(log_file).get(today or _today_str(), []) + if not entries: + return False + key_unavailable = compute_entry_hmac({"_probe": True}) is None + for entry in entries: + if verify_entry_hmac(entry): + return True + if key_unavailable and "hmac" not in entry: + _logger.info("HMAC key unavailable — accepting unsigned entry") + return True + _logger.warning("HMAC verification failed for today's log entries") + return False + + +def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bool: + """Return True if today has an unresolved early-bird pending marker.""" + if not pending_file.exists(): + return False + try: + with pending_file.open() as f: + state = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + _logger.warning( + "Could not read the early-bird pending marker at %s: %s — treating " + "it as not pending, so the lock chain proceeds as if no early bird " + "was claimed today", + pending_file, + exc, + ) + return False + if not isinstance(state, dict) or state.get("date") != (today or _today_str()): + return False + if verify_entry_hmac(state): + return True + if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state: + _logger.info("HMAC key unavailable — accepting unsigned pending marker") + return True + _logger.warning("HMAC verification failed for early-bird pending marker") + return False + + +def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool: + """Return True if today is recorded as a sick day. + + Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of + the status layer have one predicate module to import from. + """ + return _is_sick_day(history, today=today) + + +def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool: + """Deliberate, independent reimplementation of ``_is_early_bird_time``. + + Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many + existing tests are pinned to its ``_get_local_time_minutes`` patch point. + Kept here as a small, separate, pure re-implementation for the status + layer only — see the module docstring in ``_early_bird.py``. + """ + start = EARLY_BIRD_START_HOUR * 60 + end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE + return start <= local_minutes < end diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index 5e2e845..24e56a6 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -18,212 +18,40 @@ surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``. from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timezone -import json -import logging from typing import TYPE_CHECKING -from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac - -from screen_locker._constants import ( - EARLY_BIRD_END_HOUR, - EARLY_BIRD_END_MINUTE, - EARLY_BIRD_START_HOUR, +from screen_locker._compliance_predicates import ( + _early_bird_window_open, + has_logged_today, + is_early_bird_pending, + is_scheduled_skip_today, + is_sick_day_today, +) +from screen_locker._compliance_trace import ( + AutoUpgradeOpportunity, + LockExplanation, + PredicateResult, + _stage, + describe_auto_upgrade_opportunity, ) -from screen_locker._log_io import load_workout_log -from screen_locker._sick_tracker import is_sick_day as _is_sick_day if TYPE_CHECKING: from pathlib import Path from screen_locker._sick_tracker import SickHistory -_logger = logging.getLogger(__name__) - - -def _today_str() -> str: - """Return today's date as ``YYYY-MM-DD`` in UTC.""" - return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - - -@dataclass(frozen=True) -class PredicateResult: - """One evaluated step in a :class:`LockExplanation` trace.""" - - name: str - fired: bool - reason: str - - -@dataclass(frozen=True) -class AutoUpgradeOpportunity: - """Describes whether the real locker would attempt a live auto-upgrade.""" - - would_attempt: bool - via: str # "early_bird_expired" | "sick_day" | "none" - reason: str - - -@dataclass(frozen=True) -class LockExplanation: - """Read-only reconstruction of what the lock-decision chain would do.""" - - fired: bool - stage: str - reason: str - trace: tuple[PredicateResult, ...] - auto_upgrade: AutoUpgradeOpportunity - heat_skip_evaluated: bool - - -_NO_UPGRADE = AutoUpgradeOpportunity( - would_attempt=False, - via="none", - reason="No pending auto-upgrade opportunity.", -) - - -def is_scheduled_skip_today( - scheduled_skips_file: Path, *, today: str | None = None -) -> bool: - """Return True if *today* is listed in *scheduled_skips_file*.""" - if not scheduled_skips_file.exists(): - return False - try: - with scheduled_skips_file.open() as f: - skips = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - _logger.warning( - "Could not read scheduled skips from %s: %s — treating today as NOT " - "a scheduled skip (the lock chain continues)", - scheduled_skips_file, - exc, - ) - return False - return (today or _today_str()) in skips - - -def has_logged_today(log_file: Path, *, today: str | None = None) -> bool: - """Return True if a validly-signed workout is logged for *today*. - - The day may hold multiple workouts; return True if ANY of today's entries - verifies (or is acceptably unsigned when no HMAC key is configured). - """ - entries = load_workout_log(log_file).get(today or _today_str(), []) - if not entries: - return False - key_unavailable = compute_entry_hmac({"_probe": True}) is None - for entry in entries: - if verify_entry_hmac(entry): - return True - if key_unavailable and "hmac" not in entry: - _logger.info("HMAC key unavailable — accepting unsigned entry") - return True - _logger.warning("HMAC verification failed for today's log entries") - return False - - -def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bool: - """Return True if today has an unresolved early-bird pending marker.""" - if not pending_file.exists(): - return False - try: - with pending_file.open() as f: - state = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - _logger.warning( - "Could not read the early-bird pending marker at %s: %s — treating " - "it as not pending, so the lock chain proceeds as if no early bird " - "was claimed today", - pending_file, - exc, - ) - return False - if not isinstance(state, dict) or state.get("date") != (today or _today_str()): - return False - if verify_entry_hmac(state): - return True - if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state: - _logger.info("HMAC key unavailable — accepting unsigned pending marker") - return True - _logger.warning("HMAC verification failed for early-bird pending marker") - return False - - -def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool: - """Return True if today is recorded as a sick day. - - Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of - the status layer have one predicate module to import from. - """ - return _is_sick_day(history, today=today) - - -def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool: - """Deliberate, independent reimplementation of ``_is_early_bird_time``. - - Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many - existing tests are pinned to its ``_get_local_time_minutes`` patch point. - Kept here as a small, separate, pure re-implementation for the status - layer only — see the module docstring in ``_early_bird.py``. - """ - start = EARLY_BIRD_START_HOUR * 60 - end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE - return start <= local_minutes < end - - -def describe_auto_upgrade_opportunity( - *, - early_bird_pending: bool, - early_bird_window_open: bool, - is_sick_day: bool, -) -> AutoUpgradeOpportunity: - """Describe what the real ``_try_auto_upgrade_*`` methods would attempt. - - Read-only stand-in for ``AutoUpgradeMixin._check_today_state_exits``'s - branching — never calls phone/RunnerUp verification and never writes to - ``workout_log.json``. - """ - if early_bird_pending and not early_bird_window_open: - return AutoUpgradeOpportunity( - would_attempt=True, - via="early_bird_expired", - reason=( - "Early-bird window closed with no workout logged — the next " - "lock run will try phone/RunnerUp verification before " - "falling back to a full lock." - ), - ) - if is_sick_day: - return AutoUpgradeOpportunity( - would_attempt=True, - via="sick_day", - reason=( - "Sick day logged — the next lock run will try phone/RunnerUp " - "verification to upgrade it to a real workout." - ), - ) - return _NO_UPGRADE - - -def _stage( - *, - fired: bool, - stage: str, - reason: str, - trace: list[PredicateResult], - auto_upgrade: AutoUpgradeOpportunity, -) -> LockExplanation: - """Build a terminal :class:`LockExplanation` from the trace so far.""" - return LockExplanation( - fired=fired, - stage=stage, - reason=reason, - trace=tuple(trace), - auto_upgrade=auto_upgrade, - heat_skip_evaluated=False, - ) +__all__ = [ + "AutoUpgradeOpportunity", + "LockExplanation", + "PredicateResult", + "describe_auto_upgrade_opportunity", + "explain_lock_decision", + "has_logged_today", + "is_early_bird_pending", + "is_scheduled_skip_today", + "is_sick_day_today", +] def explain_lock_decision( diff --git a/screen_locker/_compliance_trace.py b/screen_locker/_compliance_trace.py new file mode 100644 index 0000000..9131e85 --- /dev/null +++ b/screen_locker/_compliance_trace.py @@ -0,0 +1,102 @@ +"""Trace dataclasses and auto-upgrade description for the lock explanation. + +Split out of :mod:`screen_locker._compliance_state` to keep every file under +the 250-line cap. Re-exported from there, so existing imports and patch +targets for the predicate functions are unchanged. + +Nothing here touches ADB, sudo, subprocess, the network or the disk. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PredicateResult: + """One evaluated step in a :class:`LockExplanation` trace.""" + + name: str + fired: bool + reason: str + + +@dataclass(frozen=True) +class AutoUpgradeOpportunity: + """Describes whether the real locker would attempt a live auto-upgrade.""" + + would_attempt: bool + via: str # "early_bird_expired" | "sick_day" | "none" + reason: str + + +@dataclass(frozen=True) +class LockExplanation: + """Read-only reconstruction of what the lock-decision chain would do.""" + + fired: bool + stage: str + reason: str + trace: tuple[PredicateResult, ...] + auto_upgrade: AutoUpgradeOpportunity + heat_skip_evaluated: bool + + +_NO_UPGRADE = AutoUpgradeOpportunity( + would_attempt=False, + via="none", + reason="No pending auto-upgrade opportunity.", +) + + +def describe_auto_upgrade_opportunity( + *, + early_bird_pending: bool, + early_bird_window_open: bool, + is_sick_day: bool, +) -> AutoUpgradeOpportunity: + """Describe what the real ``_try_auto_upgrade_*`` methods would attempt. + + Read-only stand-in for ``AutoUpgradeMixin._check_today_state_exits``'s + branching — never calls phone/RunnerUp verification and never writes to + ``workout_log.json``. + """ + if early_bird_pending and not early_bird_window_open: + return AutoUpgradeOpportunity( + would_attempt=True, + via="early_bird_expired", + reason=( + "Early-bird window closed with no workout logged — the next " + "lock run will try phone/RunnerUp verification before " + "falling back to a full lock." + ), + ) + if is_sick_day: + return AutoUpgradeOpportunity( + would_attempt=True, + via="sick_day", + reason=( + "Sick day logged — the next lock run will try phone/RunnerUp " + "verification to upgrade it to a real workout." + ), + ) + return _NO_UPGRADE + + +def _stage( + *, + fired: bool, + stage: str, + reason: str, + trace: list[PredicateResult], + auto_upgrade: AutoUpgradeOpportunity, +) -> LockExplanation: + """Build a terminal :class:`LockExplanation` from the trace so far.""" + return LockExplanation( + fired=fired, + stage=stage, + reason=reason, + trace=tuple(trace), + auto_upgrade=auto_upgrade, + heat_skip_evaluated=False, + ) diff --git a/screen_locker/_status_sections.py b/screen_locker/_status_sections.py new file mode 100644 index 0000000..52e3efa --- /dev/null +++ b/screen_locker/_status_sections.py @@ -0,0 +1,177 @@ +"""The individual rendered sections of the status window. + +Split out of :mod:`screen_locker.status_view` to keep every file under the +250-line cap. Composed into ``StatusWindow`` there, alongside the other view +mixins, so the class's public surface is unchanged. + +Every method here is display-only: it reads the snapshot it is handed and +paints widgets. Nothing in this module writes to disk or reaches the network. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from screen_locker._sync_status import format_sync_line, gather_sync_status + +if TYPE_CHECKING: + import tkinter as tk + + from screen_locker._compliance_state import LockExplanation + from screen_locker._status_data import ( + DayStatus, + ManualWorkoutBudgetStatus, + ShutdownProjection, + SickBudgetStatus, + WeeklySummary, + ) + + +class StatusSectionsMixin: + """Renders each section of the status window from a snapshot.""" + + def _section_today(self, parent: tk.Widget, day: DayStatus) -> None: + """Render today's outcome.""" + del parent + mark = "✓" if day.counted else ("😷" if day.is_sick_day else "—") + entry_str = ", ".join(day.entry_types) or ( + "sick day" if day.is_sick_day else "no entry yet" + ) + self._text( + f"{mark} Today ({day.label}): {entry_str}", + role="body", + color=self._colors.success if day.counted else self._colors.warning, + ) + if day.source: + # Secondary provenance note on an already-shown entry -- a + # deliberately caption-sized exception to the 16px floor. + self._text(day.source, role="caption", color=self._colors.muted, pad="xs") + + def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None: + """Render this ISO week's per-day breakdown and totals.""" + del parent + self._label( + f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm" + ) + for day in week.days: + mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·") + entry_str = ", ".join(day.entry_types) or ( + "sick day" if day.is_sick_day else "no entry" + ) + self._text( + f"{mark} {day.label}: {entry_str}", + role="body", + color=self._colors.muted, + pad="xs", + ) + if week.remaining > 0: + self._text( + f"Need {week.remaining} more this week.", + role="body", + color=self._colors.warning, + ) + elif week.extra > 0: + self._text( + f"{week.extra} above the weekly minimum!", + role="body", + color=self._colors.success, + ) + + def _section_lock_explanation( + self, parent: tk.Widget, expl: LockExplanation + ) -> None: + """Render why the lock did/didn't fire today, plus its evaluation trace.""" + del parent + self._label("Why the lock did/didn't fire", role="body", pad="sm") + self._text( + expl.reason, + role="body", + color=self._colors.danger if expl.fired else self._colors.success, + ) + if expl.auto_upgrade.would_attempt: + self._text( + f"Pending auto-upgrade: {expl.auto_upgrade.reason}", + role="label", + color=self._colors.warning, + ) + + def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: + """Render rolling sick-day budget usage.""" + del parent + self._label("Sick Budget", role="body", pad="sm") + self._text( + f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} " + f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}", + role="body", + color=self._colors.danger if sick.exhausted else self._colors.muted, + ) + + def _section_manual_workout_budget( + self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus + ) -> None: + """Render rolling manual-workout budget usage.""" + del parent + self._label("Manual Workout Budget", role="body", pad="sm") + self._text( + f"{manual.used_7d}/{manual.budget_7d} week · " + f"{manual.used_30d}/{manual.budget_30d} month", + role="body", + color=self._colors.danger if manual.exhausted else self._colors.muted, + ) + + def _section_sync_backend(self, parent: tk.Widget) -> None: + """Render which backend this machine syncs through. + + Read from local state only (see :mod:`screen_locker._sync_status`), so + opening or refreshing the window never makes a network call. Coloured + by ``healthy`` rather than by backend: "configured but never pushed" + looks fine everywhere else and produces no data, which is exactly the + state worth surfacing here. + """ + del parent + status = gather_sync_status() + self._label("Sync backend", role="body", pad="sm") + self._text( + format_sync_line(status), + role="body", + color=self._colors.muted if status.healthy else self._colors.danger, + ) + + def _section_shutdown( + self, parent: tk.Widget, shutdown: ShutdownProjection + ) -> None: + """Render tonight's live config, rest-of-week, and next-week preview.""" + del parent + self._label("Shutdown Time", role="body", pad="sm") + if shutdown.tonight is not None: + mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight + self._text( + f"Live config — Mon-Wed {mon_wed_hour:02d}:00, " + f"Thu-Sun {thu_sun_hour:02d}:00", + role="body", + ) + else: + self._text( + "Live shutdown config unavailable.", + role="body", + color=self._colors.warning, + ) + # Rest-of-week/next-week/explanation are speculative annotations, not + # the section's primary content -- a deliberate caption-sized + # exception to the 16px floor, all sharing one muted tone instead of + # three unrelated ad hoc grays. + rest_line = ", ".join( + f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week + ) + self._text( + f"Rest of week: {rest_line}", role="caption", color=self._colors.muted + ) + next_line = ", ".join( + f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview + ) + self._text( + f"Next week (speculative): {next_line}", + role="caption", + color=self._colors.muted, + ) + self._text(shutdown.explanation, role="caption", color=self._colors.muted) diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index 89bacdd..8048727 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -33,15 +33,11 @@ from gatelock import LockConfig from screen_locker import _manual_workout from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._status_data import ( - DayStatus, - ManualWorkoutBudgetStatus, - ShutdownProjection, - SickBudgetStatus, StatusSnapshot, - WeeklySummary, format_summary_line, gather_status, ) +from screen_locker._status_sections import StatusSectionsMixin from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier from screen_locker._surface_group import FrameGroup from screen_locker._sync_status import format_sync_line, gather_sync_status @@ -55,7 +51,6 @@ if TYPE_CHECKING: from collections.abc import Callable from concurrent.futures import Future - from screen_locker._compliance_state import LockExplanation from screen_locker._temperature import TemperatureCheck from screen_locker.screen_lock import ScreenLocker @@ -68,6 +63,7 @@ class StatusWindow( ManualWorkoutDialogMixin, TemperatureStatusMixin, PhoneCheckMixin, + StatusSectionsMixin, ): """Thin Tk view over a :class:`StatusSnapshot`. @@ -169,152 +165,6 @@ class StatusWindow( width=8, ).pack(side="left", padx=self._colors.space("sm")) - def _section_today(self, parent: tk.Widget, day: DayStatus) -> None: - """Render today's outcome.""" - del parent - mark = "✓" if day.counted else ("😷" if day.is_sick_day else "—") - entry_str = ", ".join(day.entry_types) or ( - "sick day" if day.is_sick_day else "no entry yet" - ) - self._text( - f"{mark} Today ({day.label}): {entry_str}", - role="body", - color=self._colors.success if day.counted else self._colors.warning, - ) - if day.source: - # Secondary provenance note on an already-shown entry -- a - # deliberately caption-sized exception to the 16px floor. - self._text(day.source, role="caption", color=self._colors.muted, pad="xs") - - def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None: - """Render this ISO week's per-day breakdown and totals.""" - del parent - self._label( - f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm" - ) - for day in week.days: - mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·") - entry_str = ", ".join(day.entry_types) or ( - "sick day" if day.is_sick_day else "no entry" - ) - self._text( - f"{mark} {day.label}: {entry_str}", - role="body", - color=self._colors.muted, - pad="xs", - ) - if week.remaining > 0: - self._text( - f"Need {week.remaining} more this week.", - role="body", - color=self._colors.warning, - ) - elif week.extra > 0: - self._text( - f"{week.extra} above the weekly minimum!", - role="body", - color=self._colors.success, - ) - - def _section_lock_explanation( - self, parent: tk.Widget, expl: LockExplanation - ) -> None: - """Render why the lock did/didn't fire today, plus its evaluation trace.""" - del parent - self._label("Why the lock did/didn't fire", role="body", pad="sm") - self._text( - expl.reason, - role="body", - color=self._colors.danger if expl.fired else self._colors.success, - ) - if expl.auto_upgrade.would_attempt: - self._text( - f"Pending auto-upgrade: {expl.auto_upgrade.reason}", - role="label", - color=self._colors.warning, - ) - - def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: - """Render rolling sick-day budget usage.""" - del parent - self._label("Sick Budget", role="body", pad="sm") - self._text( - f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} " - f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}", - role="body", - color=self._colors.danger if sick.exhausted else self._colors.muted, - ) - - def _section_manual_workout_budget( - self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus - ) -> None: - """Render rolling manual-workout budget usage.""" - del parent - self._label("Manual Workout Budget", role="body", pad="sm") - self._text( - f"{manual.used_7d}/{manual.budget_7d} week · " - f"{manual.used_30d}/{manual.budget_30d} month", - role="body", - color=self._colors.danger if manual.exhausted else self._colors.muted, - ) - - def _section_sync_backend(self, parent: tk.Widget) -> None: - """Render which backend this machine syncs through. - - Read from local state only (see :mod:`screen_locker._sync_status`), so - opening or refreshing the window never makes a network call. Coloured - by ``healthy`` rather than by backend: "configured but never pushed" - looks fine everywhere else and produces no data, which is exactly the - state worth surfacing here. - """ - del parent - status = gather_sync_status() - self._label("Sync backend", role="body", pad="sm") - self._text( - format_sync_line(status), - role="body", - color=self._colors.muted if status.healthy else self._colors.danger, - ) - - def _section_shutdown( - self, parent: tk.Widget, shutdown: ShutdownProjection - ) -> None: - """Render tonight's live config, rest-of-week, and next-week preview.""" - del parent - self._label("Shutdown Time", role="body", pad="sm") - if shutdown.tonight is not None: - mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight - self._text( - f"Live config — Mon-Wed {mon_wed_hour:02d}:00, " - f"Thu-Sun {thu_sun_hour:02d}:00", - role="body", - ) - else: - self._text( - "Live shutdown config unavailable.", - role="body", - color=self._colors.warning, - ) - # Rest-of-week/next-week/explanation are speculative annotations, not - # the section's primary content -- a deliberate caption-sized - # exception to the 16px floor, all sharing one muted tone instead of - # three unrelated ad hoc grays. - rest_line = ", ".join( - f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week - ) - self._text( - f"Rest of week: {rest_line}", role="caption", color=self._colors.muted - ) - next_line = ", ".join( - f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview - ) - self._text( - f"Next week (speculative): {next_line}", - role="caption", - color=self._colors.muted, - ) - self._text(shutdown.explanation, role="caption", color=self._colors.muted) - def _on_refresh_clicked(self) -> None: """Clear any stale phone-check/credit/temperature results, re-check both.""" self._phone_check_result = None diff --git a/screen_locker/tests/test_compliance_state.py b/screen_locker/tests/test_compliance_state.py index 056897a..4c6b668 100644 --- a/screen_locker/tests/test_compliance_state.py +++ b/screen_locker/tests/test_compliance_state.py @@ -79,7 +79,7 @@ class TestHasLoggedToday: log_file = tmp_path / "log.json" log_file.write_text(json.dumps({_today(): {"hmac": "sig"}})) with patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True ): assert has_logged_today(log_file) is True @@ -89,10 +89,12 @@ class TestHasLoggedToday: log_file.write_text(json.dumps({_today(): {}})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value=None + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value=None, ), ): assert has_logged_today(log_file) is True @@ -103,10 +105,12 @@ class TestHasLoggedToday: log_file.write_text(json.dumps({_today(): {}})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value="sig", ), ): assert has_logged_today(log_file) is False @@ -117,10 +121,12 @@ class TestHasLoggedToday: log_file.write_text(json.dumps({_today(): {"hmac": "tampered"}})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value=None + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value=None, ), ): assert has_logged_today(log_file) is False @@ -156,7 +162,7 @@ class TestIsEarlyBirdPending: pending_file = tmp_path / "pending.json" pending_file.write_text(json.dumps({"date": _today(), "hmac": "sig"})) with patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True ): assert is_early_bird_pending(pending_file) is True @@ -166,10 +172,12 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": _today()})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value=None + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value=None, ), ): assert is_early_bird_pending(pending_file) is True @@ -180,10 +188,12 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": _today()})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value="sig", ), ): assert is_early_bird_pending(pending_file) is False @@ -194,10 +204,12 @@ class TestIsEarlyBirdPending: pending_file.write_text(json.dumps({"date": _today(), "hmac": "bad"})) with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value=None + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value=None, ), ): assert is_early_bird_pending(pending_file) is False diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index 9634cd4..d308da4 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -49,7 +49,7 @@ class TestExplainLockDecision: ) now = datetime.now(tz=timezone.utc).astimezone().replace(hour=6, minute=0) with patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True ): result = explain_lock_decision( **files, @@ -71,7 +71,7 @@ class TestExplainLockDecision: ) now = datetime.now(tz=timezone.utc).astimezone().replace(hour=10, minute=0) with patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True ): result = explain_lock_decision( **files, @@ -106,7 +106,7 @@ class TestExplainLockDecision: files = self._files(tmp_path) files["log_file"].write_text(json.dumps({_today(): {"hmac": "sig"}})) with patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True ): result = explain_lock_decision( **files, diff --git a/screen_locker/tests/test_early_bird_part3.py b/screen_locker/tests/test_early_bird_part3.py index dd9dd20..2a62f7d 100644 --- a/screen_locker/tests/test_early_bird_part3.py +++ b/screen_locker/tests/test_early_bird_part3.py @@ -87,10 +87,12 @@ class TestIsEarlyBirdPending: with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value="sig" + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value="sig", ), ): assert locker._is_early_bird_pending() is False @@ -109,7 +111,8 @@ class TestIsEarlyBirdPending: with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=True, ), ): assert locker._is_early_bird_pending() is True @@ -128,10 +131,12 @@ class TestIsEarlyBirdPending: with ( patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file), patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=False + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", return_value=None + "screen_locker._compliance_predicates.compute_entry_hmac", + return_value=None, ), ): assert locker._is_early_bird_pending() is True diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index 6efdf02..4728fe0 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -128,7 +128,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "screen_locker._compliance_state.verify_entry_hmac", + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=True, ): assert locker.has_logged_today() is True @@ -149,7 +149,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "screen_locker._compliance_state.verify_entry_hmac", + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=False, ): assert locker.has_logged_today() is False @@ -171,11 +171,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", + "screen_locker._compliance_predicates.compute_entry_hmac", return_value=None, ), ): @@ -198,11 +198,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "screen_locker._compliance_state.verify_entry_hmac", + "screen_locker._compliance_predicates.verify_entry_hmac", return_value=False, ), patch( - "screen_locker._compliance_state.compute_entry_hmac", + "screen_locker._compliance_predicates.compute_entry_hmac", return_value="some-signature", ), ): diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index c06045a..8fc918b 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -75,7 +75,8 @@ class TestGatherStatus: "screen_locker._status_data.has_workout_skip_today", return_value=False ), patch( - "screen_locker._compliance_state.verify_entry_hmac", return_value=True + "screen_locker._compliance_predicates.verify_entry_hmac", + return_value=True, ), ): snap = gather_status(**files, now=_FRIDAY_NOON_UTC)