mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +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
251 lines
9.4 KiB
Python
Executable File
251 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Assert every lock screen fits a 1366x768 display.
|
|
|
|
A lock surface is fullscreen, grabbed and (in production) VT-locked. Content
|
|
that does not fit is only reachable by scrolling a screen that should never
|
|
have needed a scrollbar -- and the scroll that revealed it used to happen on
|
|
its own, moving the screen under a user who had touched nothing (2026-08-03).
|
|
The runtime half of this guarantee is the ``warning``
|
|
``ScrollableSurface.finalize`` logs; this script is the half that runs before
|
|
the code ships, so a layout change cannot quietly reintroduce overflow.
|
|
|
|
``StatusWindow`` is absent: it is a resizable window, not a lock surface.
|
|
|
|
Not a pytest test: conftest replaces ``tk`` so no test reaches a display, and
|
|
heights need real rendering. It re-executes itself under Xvfb:
|
|
|
|
cd ~/src/screen-locker && python3 -m scripts.verify_screen_fits
|
|
|
|
Run as a *module*: ``-m`` puts the repo root on ``sys.path``, so
|
|
``screen_locker`` resolves from the checkout (as a path it died in CI).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import LockConfig, measure_fit, report_fit
|
|
|
|
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
|
|
from screen_locker._heat_skip import build_heat_skip_content
|
|
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
|
from screen_locker._surface_group import FrameGroup
|
|
from screen_locker._ui_flows import UIFlowsMixin
|
|
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
|
|
from screen_locker._ui_widgets import UIWidgetsMixin
|
|
from scripts._xvfb_reexec import reexec_under_xvfb
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
import tkinter as tk
|
|
|
|
from gatelock import FitResult, ScrollableSurface
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
INNER_ENV = "_SCREEN_FITS_CHECK_INNER"
|
|
|
|
# The panel this app has to support: the laptop it runs on. Shorter panels
|
|
# get gatelock's best-effort compaction (``gatelock._density``) but are not
|
|
# gated here -- no machine runs one, and a target nobody uses is a constraint
|
|
# that only ever costs.
|
|
SIZES: tuple[tuple[int, int], ...] = ((1366, 768),)
|
|
|
|
# Long enough to be realistic: a message that wraps to three lines is a
|
|
# different height than one that does not, and this is what the phone check
|
|
# actually hands the retry screen.
|
|
LONG_MESSAGE = (
|
|
"✘ Workout JSON is from 2026-07-27, not today\n"
|
|
"Neither StrongLifts nor RunnerUp found a workout today.\n"
|
|
"Go do your workout first!"
|
|
)
|
|
|
|
# sickdays' FreeDayConflictError text: its longest refusal.
|
|
SICK_REFUSAL = (
|
|
"2026-10-16 is a free day -- release it with 'freedays release' before "
|
|
"marking it sick"
|
|
)
|
|
|
|
|
|
class ScreenHost(
|
|
UIWidgetsMixin,
|
|
UIFlowsMixin,
|
|
UIFlowsRelaxedMixin,
|
|
ManualWorkoutDialogMixin,
|
|
):
|
|
"""The real screen-painting mixins, over a throwaway surface.
|
|
|
|
Only the attributes the paint methods read are set. Nothing that reaches a
|
|
phone, the network or a background thread is called: each screen is
|
|
painted directly, with the message its flow would have been handed.
|
|
|
|
The ``paint_*`` wrappers exist so the measurement list below never reaches
|
|
into the mixins' private surface from outside the class.
|
|
"""
|
|
|
|
def __init__(self, surface: ScrollableSurface) -> None:
|
|
"""Point the mixins at one surface's content frame."""
|
|
self.root = surface.content.winfo_toplevel()
|
|
self.demo_mode = False
|
|
self._relaxed_day_mode = False
|
|
# Deliberately NOT the real log.json: is_budget_exhausted() reads it,
|
|
# and the real file's content depends on today's actual usage --
|
|
# exhausting the manual-workout budget in production made this crash
|
|
# (paint_manual_form_with_error touches _mw_error_label, which the
|
|
# budget-exhausted branch of _show_manual_workout_form never sets).
|
|
# A path that can never exist keeps every measured screen's shape
|
|
# independent of live data, which is the same guarantee every other
|
|
# attribute on this class already gives (see class docstring).
|
|
self.log_file = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "screen_locker"
|
|
/ "fit_check_never_exists.json"
|
|
)
|
|
self._colors = LockConfig()
|
|
self.container = FrameGroup([surface.content])
|
|
self.lockout_time = 1800
|
|
self.workout_data: dict[str, str] = {}
|
|
|
|
# -- hooks the flows call on submit/cancel; no screen here is submitted --
|
|
|
|
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
|
|
"""Unused: no form is submitted."""
|
|
|
|
def _on_manual_workout_cancelled(self) -> None:
|
|
"""Unused: BACK is never pressed."""
|
|
|
|
def unlock_screen(self) -> None:
|
|
"""Unused: nothing unlocks during a measurement."""
|
|
|
|
def close(self) -> None:
|
|
"""Unused: no screen is closed during a measurement."""
|
|
|
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
|
"""Never reached -- present so a stray call fails loudly, not silently."""
|
|
msg = "the fit check must not run the phone check"
|
|
raise AssertionError(msg)
|
|
|
|
# -- public paint wrappers, one per screen under measurement --
|
|
|
|
def paint_retry(self) -> None:
|
|
"""Paint the "no workout found" retry screen."""
|
|
self._show_retry_and_sick(LONG_MESSAGE)
|
|
|
|
def paint_manual_form(self) -> None:
|
|
"""Paint the manual-workout evidence form."""
|
|
self._show_manual_workout_form()
|
|
|
|
def paint_manual_form_with_error(self) -> None:
|
|
"""Paint the form as it looks after a rejected submission.
|
|
|
|
Measured separately because the validation message is a line the empty
|
|
form does not have, and "fits until the user gets something wrong" is
|
|
not fitting.
|
|
"""
|
|
self._show_manual_workout_form()
|
|
self._mw_error_label.config(
|
|
text=(
|
|
"Start time must look like HH:MM, and every reflection needs "
|
|
f"at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} characters."
|
|
)
|
|
)
|
|
|
|
def paint_sick_refused(self) -> None:
|
|
"""Paint the screen shown when today cannot be marked sick."""
|
|
self._show_sick_refused(SICK_REFUSAL)
|
|
|
|
def paint_phone_penalty(self) -> None:
|
|
"""Paint the no-phone penalty screen (countdown + I'm sick)."""
|
|
self._show_phone_penalty(LONG_MESSAGE)
|
|
|
|
def paint_relaxed_prompt(self) -> None:
|
|
"""Paint the optional Tue-Thu prompt (Skip / Log / I'm sick)."""
|
|
self._start_relaxed_day_flow()
|
|
|
|
def paint_relaxed_retry(self) -> None:
|
|
"""Paint the relaxed-day (Tue-Thu) retry screen."""
|
|
self._show_relaxed_retry(LONG_MESSAGE, "no_workout")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Screen:
|
|
"""One screen to measure, named by the paint call that produces it."""
|
|
|
|
name: str
|
|
paint: Callable[[ScreenHost], None]
|
|
|
|
|
|
def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc:
|
|
"""Paint the heat-skip screen and return the frame to measure.
|
|
|
|
Built through its extracted content builder because in production it owns
|
|
a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure.
|
|
"""
|
|
return build_heat_skip_content(
|
|
surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None
|
|
)
|
|
|
|
|
|
SCREENS: tuple[Screen, ...] = (
|
|
Screen("phone-check", ScreenHost.paint_phone_check),
|
|
Screen("no-workout-retry", ScreenHost.paint_retry),
|
|
Screen("manual-workout-form", ScreenHost.paint_manual_form),
|
|
Screen("manual-workout-form+error", ScreenHost.paint_manual_form_with_error),
|
|
Screen("sick-refused", ScreenHost.paint_sick_refused),
|
|
Screen("phone-penalty", ScreenHost.paint_phone_penalty),
|
|
Screen("relaxed-day-prompt", ScreenHost.paint_relaxed_prompt),
|
|
Screen("lockout", ScreenHost.lockout),
|
|
Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry),
|
|
)
|
|
|
|
# Screens built without a ScreenHost, measured through their own builder.
|
|
EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),)
|
|
|
|
|
|
def measure_all() -> list[FitResult]:
|
|
"""Measure every screen at every supported size."""
|
|
results: list[FitResult] = []
|
|
for width, height in SIZES:
|
|
for screen in SCREENS:
|
|
|
|
def build(surface: ScrollableSurface, paint: Screen = screen) -> None:
|
|
paint.paint(ScreenHost(surface))
|
|
|
|
results.append(measure_fit(screen.name, build, width=width, height=height))
|
|
results.extend(
|
|
measure_fit(name, build, width=width, height=height)
|
|
for name, build in EXTRA_SCREENS
|
|
)
|
|
return results
|
|
|
|
|
|
def main() -> int:
|
|
"""Measure every screen and fail if any of them overflows."""
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
|
|
if os.environ.get(INNER_ENV) != "1":
|
|
return reexec_under_xvfb(
|
|
inner_env=INNER_ENV, module="scripts.verify_screen_fits"
|
|
)
|
|
|
|
_logger.info("Measuring every lock screen against every supported panel:")
|
|
results = measure_all()
|
|
if report_fit(results):
|
|
_logger.info("\nOK: every screen fits without scrolling.")
|
|
return 0
|
|
_logger.error(
|
|
"\nAt least one screen needs a scrollbar to be usable. Shrink the "
|
|
"layout (design-system tokens, gatelock._density) rather than "
|
|
"accepting the scroll -- a lock screen the user has to scroll is the "
|
|
"defect this check exists to prevent."
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|