mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:47:39 +01:00
Make the Tk locker screens text-free
Every locker screen, the status window and the credit toast now show glyphs (screen_locker/_glyphs.py) and numbers, coloured by state. Sentences that carried a reason (verifier message, sync diagnosis, sick refusal, form rejection) go to the log at warning instead. New pre-commit hook text-free runs scripts/verify_text_free.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3073faa623
commit
e5bfdd9a9a
@ -298,6 +298,13 @@ repos:
|
||||
files: '^screen_locker/.*\.py$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: text-free
|
||||
name: No locker screen paints a word (textgate)
|
||||
entry: python3 -m scripts.verify_text_free
|
||||
language: system
|
||||
files: '^(screen_locker|scripts)/.*\.py$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: flutter-analyze
|
||||
name: flutter analyze (workout app)
|
||||
# env -u GIT_DIR: git exports it to hooks, and flutter shells out to
|
||||
|
||||
@ -34,7 +34,12 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
TITLE: Final = "Workout credited \N{FLEXED BICEPS}"
|
||||
# textgate: glyphs + numbers (colour emoji are safe in the notification
|
||||
# daemon, unlike Tk). Was "Workout credited 💪" / "Shutdown 23:00 (+2h) ·
|
||||
# gaming 7.0h" / "Credit applied (details unavailable)".
|
||||
TITLE: Final = "\N{FLEXED BICEPS} \N{CHECK MARK}"
|
||||
_SHUTDOWN: Final = "\N{POWER SYMBOL}"
|
||||
_GAMING: Final = "\N{VIDEO GAME}"
|
||||
_NOTIFY_TIMEOUT_SECONDS: Final = 5
|
||||
_MINUTES_PER_HOUR: Final = 60.0
|
||||
|
||||
@ -62,7 +67,7 @@ def credit_body(
|
||||
after: tuple[int, int, int] | None,
|
||||
gaming_minutes: int | None,
|
||||
) -> str:
|
||||
"""``Shutdown 23:00 (+2h) · gaming 7.0h`` -- whichever parts are known.
|
||||
"""``⏻ 23:00 (+2h) · 🎮 7.0h`` -- whichever parts are known.
|
||||
|
||||
``before``/``after`` are the shutdown config around the credit; the delta
|
||||
is tonight's actual move, so a capped or rest-day credit shows ``+0h``.
|
||||
@ -76,14 +81,14 @@ def credit_body(
|
||||
else:
|
||||
day = date.fromisoformat(today_str())
|
||||
tonight = tonight_minutes(after, day)
|
||||
text = f"Shutdown {hhmm(tonight)}"
|
||||
text = f"{_SHUTDOWN} {hhmm(tonight)}"
|
||||
if before is not None:
|
||||
moved = max(0, tonight - tonight_minutes(before, day))
|
||||
text += f" (+{span(moved)})"
|
||||
parts.append(text)
|
||||
if gaming_minutes is not None:
|
||||
parts.append(f"gaming {gaming_minutes / _MINUTES_PER_HOUR:.1f}h")
|
||||
return " \N{MIDDLE DOT} ".join(parts) or "Credit applied (details unavailable)"
|
||||
parts.append(f"{_GAMING} {gaming_minutes / _MINUTES_PER_HOUR:.1f}h")
|
||||
return " \N{MIDDLE DOT} ".join(parts) or "\N{CHECK MARK}"
|
||||
|
||||
|
||||
def send_notification(title: str, body: str) -> bool:
|
||||
|
||||
98
screen_locker/_glyphs.py
Normal file
98
screen_locker/_glyphs.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""The locker's pictograms: one place, so a glyph means one thing everywhere.
|
||||
|
||||
textgate (kuhy, 2026-11-08): nothing he sees carries words. Every screen is
|
||||
built from these plus numbers, each paired with a palette colour as the
|
||||
second channel (success / warning / danger / muted). Plain geometric
|
||||
characters only: a colour-emoji fallback font can crash Tk 8.6, so no
|
||||
U+1Fxxx and nothing with an emoji presentation by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
LIFT: Final = "●━━●" # a barbell: the StrongLifts workout / "go work out"
|
||||
RUN: Final = "⇝" # a RunnerUp run
|
||||
PHONE: Final = "▯" # the phone (adb)
|
||||
WAIT: Final = "⋯" # checking / unlocking in progress
|
||||
OK: Final = "✓"
|
||||
FAIL: Final = "✗"
|
||||
RETRY: Final = "↻"
|
||||
CLOSE: Final = "✕"
|
||||
BACK: Final = "↩"
|
||||
GO: Final = "▶"
|
||||
SKIP: Final = "↷"
|
||||
SYNC: Final = "⇅" # a sync backend (Firebase)
|
||||
CLOCK: Final = "◷" # time, the system clock
|
||||
SHORT: Final = "◔" # a session shorter than the minimum
|
||||
FORM: Final = "≡" # the manual-workout evidence form
|
||||
SICK: Final = "✚" # a sick day
|
||||
SHUTDOWN: Final = "⏻" # the PC's shutdown time
|
||||
STREAK: Final = "▰" # one week of the streak
|
||||
WEEK_DONE: Final = "●" # a counted day
|
||||
WEEK_SICK: Final = "✚" # a sick day in the week row
|
||||
WEEK_EMPTY: Final = "○" # a day with nothing counted
|
||||
HEAT: Final = "▲" # the outdoor temperature
|
||||
LOCK: Final = "■" # the lock fired
|
||||
UNLOCK: Final = "□" # the lock stood down
|
||||
OPTIONAL: Final = "◇" # Tue-Thu: a workout is optional today
|
||||
GIFT: Final = "+" # an earned reward, always followed by its amount
|
||||
|
||||
# Startup phone-check status -> the glyph that replaces its sentence.
|
||||
STATUS_GLYPH: Final[dict[str, str]] = {
|
||||
"too_short": SHORT,
|
||||
"clock_tampered": f"{CLOCK}{FAIL}",
|
||||
"stale": f"{CLOCK}{FAIL}",
|
||||
"no_exercises": f"{LIFT} 0",
|
||||
"not_verified": FAIL,
|
||||
"no_phone": f"{PHONE}{FAIL}",
|
||||
"error": f"{PHONE}{FAIL}",
|
||||
}
|
||||
|
||||
|
||||
# log.json entry ``type`` -> glyph, for the status window's day cells.
|
||||
ENTRY_GLYPH: Final[dict[str, str]] = {
|
||||
"phone_verified": LIFT,
|
||||
"strength": LIFT,
|
||||
"pc_workout_verified": LIFT,
|
||||
"runnerup_verified": RUN,
|
||||
"running": RUN,
|
||||
"manual_workout": FORM,
|
||||
"heat_skip": HEAT,
|
||||
"relaxed_day_skip": SKIP,
|
||||
"early_bird": OK,
|
||||
}
|
||||
|
||||
|
||||
# Lock-decision stage (``_compliance_state._STEPS`` names) -> glyph. A stage
|
||||
# not listed shows only the lock/unlock square and its colour.
|
||||
STAGE_GLYPH: Final[dict[str, str]] = {
|
||||
"scheduled_skip": SKIP,
|
||||
"early_bird_pending_expired": CLOCK,
|
||||
"early_bird_window_open": CLOCK,
|
||||
"sick_day": SICK,
|
||||
"already_logged": OK,
|
||||
"wake_alarm_skip": CLOCK,
|
||||
"test_hold_active": WAIT,
|
||||
"relaxed_day_already_skipped": f"{OPTIONAL}{SKIP}",
|
||||
"relaxed_day": OPTIONAL,
|
||||
"weekly_minimum_met": WEEK_DONE,
|
||||
"evening_lockdown_today": SHUTDOWN,
|
||||
"would_lock": LIFT,
|
||||
}
|
||||
|
||||
|
||||
def entry_glyphs(types: tuple[str, ...]) -> str:
|
||||
"""Glyphs for a day's entry types; an unknown type shows as ``OK``."""
|
||||
return " ".join(ENTRY_GLYPH.get(t, OK) for t in types)
|
||||
|
||||
|
||||
def dots(done: int, total: int) -> str:
|
||||
"""``●●○○ 2/4``: progress as filled dots plus the numbers (two channels)."""
|
||||
filled = max(0, min(done, total))
|
||||
return f"{WEEK_DONE * filled}{WEEK_EMPTY * (total - filled)} {done}/{total}"
|
||||
|
||||
|
||||
def status_glyph(status: str) -> str:
|
||||
"""Return the glyph for a phone-check *status* (``FAIL`` if unknown)."""
|
||||
return STATUS_GLYPH.get(status, FAIL)
|
||||
@ -56,10 +56,8 @@ def build_heat_skip_content(
|
||||
|
||||
tk.Label(
|
||||
outer,
|
||||
text=(
|
||||
f"{HEAT_SKIP_CITY} {temp:.0f}\u00b0C"
|
||||
f" \u2265 {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C"
|
||||
),
|
||||
# The city name was dropped too: the reading is what matters.
|
||||
text=f"{temp:.0f}\u00b0C \u2265 {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C",
|
||||
font=_COLORS.font("body", family=_FONT),
|
||||
bg=_COLORS.palette.bg,
|
||||
fg=_COLORS.palette.warning,
|
||||
|
||||
@ -32,6 +32,7 @@ from screen_locker._manual_workout_checks import (
|
||||
from screen_locker._manual_workout_types import (
|
||||
MANUAL_WORKOUT_TYPE,
|
||||
SPORT_CHOICES,
|
||||
SPORT_GLYPHS,
|
||||
SPORT_LABELS,
|
||||
SPORT_OTHER,
|
||||
SPORT_TABLE_TENNIS,
|
||||
@ -44,6 +45,7 @@ from screen_locker._manual_workout_types import (
|
||||
__all__ = [
|
||||
"MANUAL_WORKOUT_TYPE",
|
||||
"SPORT_CHOICES",
|
||||
"SPORT_GLYPHS",
|
||||
"SPORT_LABELS",
|
||||
"SPORT_OTHER",
|
||||
"SPORT_TABLE_TENNIS",
|
||||
|
||||
@ -13,6 +13,7 @@ the form shows a "scheduled" screen that returns to the host's cancelled hook
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -34,6 +35,10 @@ SCHEDULED_GLYPH = "✓"
|
||||
"""Heading of the scheduled screen (success colour)."""
|
||||
BACK_GLYPH = "↩"
|
||||
"""The scheduled screen's only button: back to the form's host."""
|
||||
REJECTED_GLYPH = "✗"
|
||||
"""Form rejected (danger); was the validator's sentence (now logged)."""
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ManualWorkoutDateMixin:
|
||||
@ -87,19 +92,29 @@ class ManualWorkoutDateMixin:
|
||||
if error is None and not future:
|
||||
error = budget_error(self.log_file, date)
|
||||
if error is not None:
|
||||
self._mw_error_label.config(text=error)
|
||||
self._mw_reject(error)
|
||||
return True
|
||||
if not future:
|
||||
return False
|
||||
result = schedule_future_workout(self.log_file, draft, date)
|
||||
if not result.scheduled:
|
||||
self._mw_error_label.config(text=result.reason)
|
||||
self._mw_reject(result.reason)
|
||||
return True
|
||||
self._show_manual_workout_scheduled(result.reason)
|
||||
_logger.info("Manual workout scheduled for %s: %s", date, result.reason)
|
||||
self._show_manual_workout_scheduled(date)
|
||||
return True
|
||||
|
||||
def _show_manual_workout_scheduled(self, reason: str) -> None:
|
||||
"""Confirm a future entry and go back; it does NOT unlock anything."""
|
||||
def _mw_reject(self, reason: str) -> None:
|
||||
"""Show the form's fail mark; the reason goes to the log (textgate)."""
|
||||
_logger.warning("Manual workout date/budget rejected: %s", reason)
|
||||
self._mw_error_label.config(text=REJECTED_GLYPH)
|
||||
|
||||
def _show_manual_workout_scheduled(self, date: str) -> None:
|
||||
"""Confirm a future entry and go back; it does NOT unlock anything.
|
||||
|
||||
Shows the scheduled *date* (content) under the tick; the scheduler's
|
||||
sentence is logged by the caller.
|
||||
"""
|
||||
self.clear_container()
|
||||
# Was "✓ Workout Scheduled" + "Nothing is credited today. It counts by
|
||||
# itself on that date, and can be deleted in the phone app until
|
||||
@ -111,7 +126,7 @@ class ManualWorkoutDateMixin:
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(reason, role="subtitle", color=self._colors.palette.success)
|
||||
self._text(date, role="subtitle", color=self._colors.palette.success)
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
|
||||
@ -23,7 +23,11 @@ from screen_locker import _manual_workout
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
)
|
||||
from screen_locker._manual_workout_date import BACK_GLYPH, ManualWorkoutDateMixin
|
||||
from screen_locker._manual_workout_date import (
|
||||
BACK_GLYPH,
|
||||
REJECTED_GLYPH,
|
||||
ManualWorkoutDateMixin,
|
||||
)
|
||||
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
||||
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
||||
|
||||
@ -94,24 +98,23 @@ class ManualWorkoutDialogMixin(
|
||||
self._mw_rpe_var = tk.IntVar(value=5)
|
||||
self._mw_text_widgets: dict[str, tk.Text] = {}
|
||||
|
||||
self._mw_section(form, "Basics")
|
||||
# textgate: every prompt is a glyph; optional fields are in
|
||||
# parentheses; section headings are plain rules. The old prompts were
|
||||
# the Basics heading, start and end time, the location heading and
|
||||
# name, how you got there, the cost in PLN, the optional reservation
|
||||
# phone number, and the activity-details heading.
|
||||
self._mw_section(form)
|
||||
self._mw_sport_row(form)
|
||||
self._mw_vars["start_time"] = self._mw_entry(
|
||||
form, "Start time (HH:MM):", focus=True
|
||||
)
|
||||
self._mw_vars["end_time"] = self._mw_entry(form, "End time (HH:MM):")
|
||||
self._mw_vars["start_time"] = self._mw_entry(form, "◷▶", focus=True)
|
||||
self._mw_vars["end_time"] = self._mw_entry(form, "◷■")
|
||||
|
||||
self._mw_section(form, "Location & logistics")
|
||||
self._mw_vars["location_name"] = self._mw_entry(form, "Location name:")
|
||||
self._mw_vars["transport_method"] = self._mw_entry(
|
||||
form, "How did you get there?"
|
||||
)
|
||||
self._mw_vars["cost"] = self._mw_entry(form, "Cost (e.g. 40 PLN):")
|
||||
self._mw_vars["reservation_phone"] = self._mw_entry(
|
||||
form, "Reservation phone number (if booked by phone, optional):"
|
||||
)
|
||||
self._mw_section(form)
|
||||
self._mw_vars["location_name"] = self._mw_entry(form, "⌖")
|
||||
self._mw_vars["transport_method"] = self._mw_entry(form, "⇄")
|
||||
self._mw_vars["cost"] = self._mw_entry(form, "¤")
|
||||
self._mw_vars["reservation_phone"] = self._mw_entry(form, "(▯#)")
|
||||
|
||||
self._mw_section(form, "Activity details")
|
||||
self._mw_section(form)
|
||||
# Both sport sub-frames occupy the same full-width grid slot; only the
|
||||
# selected one is shown (see _on_mw_sport_changed).
|
||||
act_row = self._mw_next_full_row(form)
|
||||
@ -131,33 +134,22 @@ class ManualWorkoutDialogMixin(
|
||||
self._build_table_tennis_fields(self._mw_tt_frame)
|
||||
self._build_other_sport_fields(self._mw_other_frame)
|
||||
self._mw_rpe_row(form)
|
||||
self._mw_vars["techniques_practiced"] = self._mw_entry(
|
||||
form, "Techniques/focus areas practiced (optional):"
|
||||
)
|
||||
self._mw_vars["warm_up_minutes"] = self._mw_entry(
|
||||
form, "Warm-up duration (optional):"
|
||||
)
|
||||
self._mw_vars["pain_or_injury"] = self._mw_entry(
|
||||
form, "Pain or injury notes (optional, default none):"
|
||||
)
|
||||
# Was "Techniques/focus areas practiced (optional):", "Warm-up
|
||||
# duration (optional):", "Pain or injury notes (optional, ...)".
|
||||
self._mw_vars["techniques_practiced"] = self._mw_entry(form, "(◎)")
|
||||
self._mw_vars["warm_up_minutes"] = self._mw_entry(form, "(∿)")
|
||||
self._mw_vars["pain_or_injury"] = self._mw_entry(form, "(↯)")
|
||||
self._on_mw_sport_changed(
|
||||
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
||||
)
|
||||
|
||||
self._mw_section(form, "Reflection")
|
||||
self._mw_text_widgets["went_well"] = self._mw_textbox(
|
||||
form,
|
||||
f"What went well (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
||||
)
|
||||
self._mw_text_widgets["to_improve"] = self._mw_textbox(
|
||||
form,
|
||||
f"What to improve (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
||||
)
|
||||
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(
|
||||
form,
|
||||
"Overall feeling about the session "
|
||||
f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
||||
)
|
||||
# Was "Reflection", "What went well / What to improve / Overall
|
||||
# feeling about the session (min N chars):".
|
||||
self._mw_section(form)
|
||||
least = f"≥{MANUAL_WORKOUT_REFLECTION_MIN_CHARS}"
|
||||
self._mw_text_widgets["went_well"] = self._mw_textbox(form, f"+ {least}")
|
||||
self._mw_text_widgets["to_improve"] = self._mw_textbox(form, f"↑ {least}")
|
||||
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(form, f"~ {least}")
|
||||
|
||||
self._mw_error_label = self._text(
|
||||
"", role="label", color=self._colors.palette.danger, pad="xs"
|
||||
@ -219,7 +211,9 @@ class ManualWorkoutDialogMixin(
|
||||
)
|
||||
error = _manual_workout.validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
self._mw_error_label.config(text=error)
|
||||
# textgate: the reason is logged; the form shows the fail mark.
|
||||
_logger.warning("Manual workout form rejected: %s", error)
|
||||
self._mw_error_label.config(text=REJECTED_GLYPH)
|
||||
return
|
||||
if self._mw_handled_by_date(draft):
|
||||
return
|
||||
|
||||
@ -50,10 +50,10 @@ class ManualWorkoutSportFieldsMixin:
|
||||
)
|
||||
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
||||
# textgate: the "Sport:" caption is gone; the radio row is the cue.
|
||||
for label in _manual_workout.SPORT_LABELS.values():
|
||||
for code, label in _manual_workout.SPORT_LABELS.items():
|
||||
tk.Radiobutton(
|
||||
row,
|
||||
text=label,
|
||||
text=_manual_workout.SPORT_GLYPHS[code],
|
||||
value=label,
|
||||
variable=self._mw_sport_var,
|
||||
# Radiobutton's command takes no argument -- bind the label.
|
||||
@ -91,25 +91,25 @@ class ManualWorkoutSportFieldsMixin:
|
||||
|
||||
def _build_table_tennis_fields(self, parent: tk.Widget) -> None:
|
||||
"""Build the table-tennis-specific score/equipment fields."""
|
||||
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:")
|
||||
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:")
|
||||
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:")
|
||||
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:")
|
||||
self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:")
|
||||
self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:")
|
||||
# textgate: was "Matches won/lost:", "Sets won/lost:", "Racket used:",
|
||||
# "Balls used:". Match / set glyph + win / loss mark.
|
||||
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "◍✓")
|
||||
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "◍✗")
|
||||
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "▤✓")
|
||||
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "▤✗")
|
||||
self._mw_vars["racket"] = self._mw_entry(parent, "◖")
|
||||
self._mw_vars["balls"] = self._mw_entry(parent, "•")
|
||||
|
||||
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
|
||||
"""Build the generic "other sport" fields."""
|
||||
self._mw_vars["activity_type_other"] = self._mw_entry(
|
||||
parent, "What sport/activity:"
|
||||
)
|
||||
# Was "What sport/activity:" / "What was done (min N chars):" /
|
||||
# "Equipment used (optional):"; optional fields are in parentheses.
|
||||
self._mw_vars["activity_type_other"] = self._mw_entry(parent, "✱")
|
||||
self._mw_text_widgets["activity_details"] = self._mw_textbox(
|
||||
parent,
|
||||
f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):",
|
||||
)
|
||||
self._mw_vars["equipment"] = self._mw_entry(
|
||||
parent, "Equipment used (optional):"
|
||||
f"✱≡ ≥{MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS}",
|
||||
)
|
||||
self._mw_vars["equipment"] = self._mw_entry(parent, "(⊡)")
|
||||
|
||||
def _current_mw_sport(self) -> str:
|
||||
"""Return the internal sport code for the currently-selected label."""
|
||||
|
||||
@ -37,6 +37,12 @@ SPORT_LABELS: dict[str, str] = {
|
||||
SPORT_TABLE_TENNIS: "Table tennis",
|
||||
SPORT_OTHER: "Other",
|
||||
}
|
||||
# What the form paints for each sport (textgate); SPORT_LABELS stays the
|
||||
# radio value and the data, never painted.
|
||||
SPORT_GLYPHS: dict[str, str] = {
|
||||
SPORT_TABLE_TENNIS: "◍",
|
||||
SPORT_OTHER: "✱",
|
||||
}
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
@ -133,12 +139,16 @@ def budget_summary(
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""One-line UI summary string for the manual-workout budget."""
|
||||
"""One-line UI summary of the manual-workout budget: glyph and numbers.
|
||||
|
||||
``≡ used/7-day budget · used/30-day budget`` (textgate; was
|
||||
``Manual: 0/2w · 0/10m``). The shorter window always comes first.
|
||||
"""
|
||||
week = count_in_window(log_file, 7, today=today)
|
||||
month = count_in_window(log_file, 30, today=today)
|
||||
return (
|
||||
f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · "
|
||||
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m"
|
||||
f"≡ {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS} · "
|
||||
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -66,17 +66,10 @@ class ManualWorkoutFormWidgetsMixin:
|
||||
counters[id(parent)] = idx + 2
|
||||
return idx // 2
|
||||
|
||||
def _mw_section(self, parent: tk.Widget, title: str) -> None:
|
||||
"""Add a full-width section heading inside the form."""
|
||||
label = tk.Label(
|
||||
parent,
|
||||
text=title,
|
||||
font=self._colors.font("body", bold=True),
|
||||
fg=self._colors.palette.accent,
|
||||
bg=self._colors.palette.bg,
|
||||
anchor="w",
|
||||
)
|
||||
self._mw_grid(parent, label, full=True)
|
||||
def _mw_section(self, parent: tk.Widget) -> None:
|
||||
"""Add a full-width rule between form sections (was a heading)."""
|
||||
rule = tk.Frame(parent, bg=self._colors.palette.accent, height=2)
|
||||
self._mw_grid(parent, rule, full=True)
|
||||
|
||||
def _mw_entry(
|
||||
self, parent: tk.Widget, label: str, *, focus: bool = False
|
||||
@ -190,10 +183,8 @@ class ManualWorkoutFormWidgetsMixin:
|
||||
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
||||
tk.Label(
|
||||
row,
|
||||
text=(
|
||||
f"RPE — perceived exertion "
|
||||
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
|
||||
),
|
||||
# Was "RPE — perceived exertion (1-10):": effort bars + range.
|
||||
text=f"▁▃▅▇ {MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}",
|
||||
font=self._colors.font("label"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
|
||||
@ -6,14 +6,19 @@ 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.
|
||||
|
||||
textgate: no section has a heading or a sentence any more. Each is one or
|
||||
two lines of glyphs (``_glyphs``) and numbers, coloured by state. Weekdays
|
||||
are positions in a Mon..Sun row, not names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._sync_status import format_sync_line, gather_sync_status
|
||||
from screen_locker._sync_status import gather_sync_status
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
@ -30,56 +35,46 @@ if TYPE_CHECKING:
|
||||
class StatusSectionsMixin:
|
||||
"""Renders each section of the status window from a snapshot."""
|
||||
|
||||
def _day_cell(self, day: DayStatus) -> str:
|
||||
"""One day as a glyph: its entries, sick, or an empty circle."""
|
||||
if day.entry_types:
|
||||
return g.entry_glyphs(day.entry_types)
|
||||
return g.WEEK_SICK if day.is_sick_day else g.WEEK_EMPTY
|
||||
|
||||
def _section_today(self, parent: tk.Widget, day: DayStatus) -> None:
|
||||
"""Render today's outcome."""
|
||||
"""Render today's outcome (was "✓ Today (Wed Nov 11): phone_verified")."""
|
||||
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",
|
||||
mark = g.OK if day.counted else (g.SICK if day.is_sick_day else g.FAIL)
|
||||
self._label(
|
||||
f"{mark} {self._day_cell(day)}",
|
||||
role="title",
|
||||
color=self._colors.palette.success
|
||||
if day.counted
|
||||
else self._colors.palette.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.palette.muted, pad="xs"
|
||||
)
|
||||
|
||||
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
|
||||
"""Render this ISO week's per-day breakdown and totals."""
|
||||
"""Render this ISO week: dots + count, then one cell per day.
|
||||
|
||||
Was "This Week: 2/4", a "Mon Nov 09: no entry" line per day and "Need
|
||||
2 more this week." / "1 above the weekly minimum!".
|
||||
"""
|
||||
del parent
|
||||
self._label(
|
||||
f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm"
|
||||
enough = week.remaining <= 0
|
||||
self._text(
|
||||
g.dots(week.counted_count, week.minimum),
|
||||
role="title",
|
||||
color=self._colors.palette.success
|
||||
if enough
|
||||
else self._colors.palette.warning,
|
||||
pad="sm",
|
||||
)
|
||||
self._text(
|
||||
" ".join(self._day_cell(day) for day in week.days),
|
||||
role="body",
|
||||
color=self._colors.palette.muted,
|
||||
pad="xs",
|
||||
)
|
||||
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.palette.muted,
|
||||
pad="xs",
|
||||
)
|
||||
if week.remaining > 0:
|
||||
self._text(
|
||||
f"Need {week.remaining} more this week.",
|
||||
role="body",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
elif week.extra > 0:
|
||||
self._text(
|
||||
f"{week.extra} above the weekly minimum!",
|
||||
role="body",
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_streak(
|
||||
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
|
||||
@ -87,29 +82,23 @@ class StatusSectionsMixin:
|
||||
"""Render the consecutive 5+-workout-week streak and this week's reward.
|
||||
|
||||
Display only, and exactly the stored values (``extra_benefits_state``):
|
||||
a streak of 0 is said plainly, never padded or rounded up. Nothing here
|
||||
grants time or relaxes a lock. The early-bird extension is not shown:
|
||||
it no longer changes anything (wake-alarm owns early bird).
|
||||
a streak of 0 is shown as 0, never padded or rounded up. Nothing here
|
||||
grants time or relaxes a lock. Was "🔥 N-week streak (5+ workouts a
|
||||
week)" / "No streak yet: ..." and the reward sentences.
|
||||
"""
|
||||
del parent
|
||||
self._label("Workout streak", role="body", pad="sm")
|
||||
if streak > 0:
|
||||
self._text(
|
||||
f"🔥 {streak}-week streak (5+ workouts a week)",
|
||||
role="body",
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
else:
|
||||
self._text(
|
||||
"No streak yet: 5+ workouts in a week starts one.",
|
||||
role="body",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
self._text(
|
||||
f"{g.STREAK * min(streak, 10)} {streak}" if streak else f"{g.STREAK} 0",
|
||||
role="body",
|
||||
color=self._colors.palette.success
|
||||
if streak
|
||||
else self._colors.palette.muted,
|
||||
)
|
||||
for message in week_rewards:
|
||||
# A reset is news, not a celebration: it must not be painted green.
|
||||
lost = message.startswith("Streak reset")
|
||||
self._text(
|
||||
message,
|
||||
f"{g.STREAK}{g.FAIL}" if lost else f"{g.STREAK}{g.GIFT}",
|
||||
role="body",
|
||||
color=self._colors.palette.warning
|
||||
if lost
|
||||
@ -119,32 +108,30 @@ class StatusSectionsMixin:
|
||||
def _section_lock_explanation(
|
||||
self, parent: tk.Widget, expl: LockExplanation
|
||||
) -> None:
|
||||
"""Render why the lock did/didn't fire today, plus its evaluation trace."""
|
||||
"""Render whether the lock fired today and the stage that decided it.
|
||||
|
||||
Was "Why the lock did/didn't fire" over the stage's sentence.
|
||||
"""
|
||||
del parent
|
||||
self._label("Why the lock did/didn't fire", role="body", pad="sm")
|
||||
square = g.LOCK if expl.fired else g.UNLOCK
|
||||
self._text(
|
||||
expl.reason,
|
||||
role="body",
|
||||
f"{square} {g.STAGE_GLYPH.get(expl.stage, '')}".rstrip(),
|
||||
role="title",
|
||||
color=self._colors.palette.danger
|
||||
if expl.fired
|
||||
else self._colors.palette.success,
|
||||
)
|
||||
if expl.auto_upgrade.would_attempt:
|
||||
self._text(
|
||||
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
|
||||
role="label",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
self._text(g.RETRY, role="label", color=self._colors.palette.warning)
|
||||
|
||||
def _section_manual_workout_budget(
|
||||
self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus
|
||||
) -> None:
|
||||
"""Render rolling manual-workout budget usage."""
|
||||
"""Render rolling manual-workout budget usage (7-day, then 30-day)."""
|
||||
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",
|
||||
f"{g.FORM} {manual.used_7d}/{manual.budget_7d} · "
|
||||
f"{manual.used_30d}/{manual.budget_30d}",
|
||||
role="body",
|
||||
color=self._colors.palette.danger
|
||||
if manual.exhausted
|
||||
@ -152,7 +139,7 @@ class StatusSectionsMixin:
|
||||
)
|
||||
|
||||
def _section_sync_backend(self, parent: tk.Widget) -> None:
|
||||
"""Render which backend this machine syncs through.
|
||||
"""Render whether this machine's sync is healthy, and its peer count.
|
||||
|
||||
Read from local state only (see :mod:`screen_locker._sync_status`), so
|
||||
opening or refreshing the window never makes a network call. Coloured
|
||||
@ -162,9 +149,8 @@ class StatusSectionsMixin:
|
||||
"""
|
||||
del parent
|
||||
status = gather_sync_status()
|
||||
self._label("Sync backend", role="body", pad="sm")
|
||||
self._text(
|
||||
format_sync_line(status),
|
||||
f"{g.SYNC} {g.OK if status.healthy else g.FAIL} {status.peer_count}",
|
||||
role="body",
|
||||
color=self._colors.palette.muted
|
||||
if status.healthy
|
||||
@ -174,41 +160,25 @@ class StatusSectionsMixin:
|
||||
def _section_shutdown(
|
||||
self, parent: tk.Widget, shutdown: ShutdownProjection
|
||||
) -> None:
|
||||
"""Render tonight's live config, rest-of-week, and next-week preview."""
|
||||
"""Render tonight's live config, rest-of-week, and next-week preview.
|
||||
|
||||
Was "Live config — Mon-Wed 21:00, Thu-Sun 22:00", "Rest of week: Mon
|
||||
21:00, ...", "Next week (speculative): ..." and an explanation. Now
|
||||
the times only, in day order: live config bold, the rest muted.
|
||||
"""
|
||||
del parent
|
||||
self._label("Shutdown Time", role="body", pad="sm")
|
||||
if shutdown.tonight is not None:
|
||||
mon_wed, thu_sun, _morning = shutdown.tonight
|
||||
self._text(
|
||||
f"Live config — Mon-Wed {hhmm(mon_wed)}, Thu-Sun {hhmm(thu_sun)}",
|
||||
role="body",
|
||||
)
|
||||
self._text(f"{g.SHUTDOWN} {hhmm(mon_wed)} · {hhmm(thu_sun)}", role="title")
|
||||
else:
|
||||
self._text(
|
||||
"Live shutdown config unavailable.",
|
||||
role="body",
|
||||
f"{g.SHUTDOWN} {g.FAIL}",
|
||||
role="title",
|
||||
color=self._colors.palette.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} {hhmm(d.minutes)}" for d in shutdown.rest_of_week
|
||||
)
|
||||
self._text(
|
||||
f"Rest of week: {rest_line}",
|
||||
role="caption",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
next_line = ", ".join(
|
||||
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.next_week_preview
|
||||
)
|
||||
self._text(
|
||||
f"Next week (speculative): {next_line}",
|
||||
role="caption",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
self._text(
|
||||
shutdown.explanation, role="caption", color=self._colors.palette.muted
|
||||
)
|
||||
for days in (shutdown.rest_of_week, shutdown.next_week_preview):
|
||||
self._text(
|
||||
" ".join(hhmm(d.minutes) for d in days),
|
||||
role="caption",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
|
||||
@ -11,10 +11,12 @@ from __future__ import annotations
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import extra_minutes, span
|
||||
from screen_locker._status_data import gather_status
|
||||
@ -29,6 +31,9 @@ if TYPE_CHECKING:
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhoneCheckOutcome:
|
||||
"""Result of the "Check Phone" button's verify-then-backfill chain.
|
||||
@ -73,7 +78,9 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
|
||||
workout above the weekly minimum); it is a separate source with its own
|
||||
dedup and is out of scope for the RunnerUp credit fix.
|
||||
|
||||
Returns a human-readable summary if anything was filled, else ``None``.
|
||||
Returns a glyph summary if anything was filled, else ``None``:
|
||||
``↻●━━● +N`` and, with a bonus, `` ⏻ +1h`` (textgate; was "Auto-filled
|
||||
N workouts from earlier this week. +1h shutdown time.").
|
||||
"""
|
||||
prev_count = count_weekly_workouts(verifier.log_file)
|
||||
filled = verifier._scan_and_fill_week_runnerup(
|
||||
@ -93,10 +100,9 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
|
||||
filled += sl_filled
|
||||
if not filled:
|
||||
return None
|
||||
plural = "workout" if filled == 1 else "workouts"
|
||||
message = f"Auto-filled {filled} {plural} from earlier this week."
|
||||
message = f"{g.RETRY}{g.LIFT} +{filled}"
|
||||
if bonus > 0:
|
||||
message += f" +{span(bonus)} shutdown time."
|
||||
message += f" {g.SHUTDOWN} +{span(bonus)}"
|
||||
return message
|
||||
|
||||
|
||||
@ -184,10 +190,8 @@ class PhoneCheckMixin:
|
||||
if outcome.credited_type == "runnerup_verified"
|
||||
else outcome.phone_message
|
||||
)
|
||||
source_label = (
|
||||
"RunnerUp"
|
||||
if outcome.credited_type == "runnerup_verified"
|
||||
else "StrongLifts"
|
||||
source_glyph = (
|
||||
g.RUN if outcome.credited_type == "runnerup_verified" else g.LIFT
|
||||
)
|
||||
verifier = self.verifier_factory(self.log_file)
|
||||
verifier.workout_data = {
|
||||
@ -196,24 +200,27 @@ class PhoneCheckMixin:
|
||||
}
|
||||
credit = verifier._apply_workout_credit()
|
||||
self._credit_message = "\n".join(
|
||||
self._credit_result_lines(f"{source_label} verified: {message}", credit)
|
||||
self._credit_result_lines(f"{source_glyph} {g.OK}", credit)
|
||||
)
|
||||
elif outcome.week_fill_message is not None:
|
||||
self._credit_message = outcome.week_fill_message
|
||||
else:
|
||||
# The reasons are logged; the window shows the failure glyphs.
|
||||
_logger.warning(
|
||||
"Check Phone found nothing: StrongLifts: %s · RunnerUp: %s",
|
||||
outcome.phone_message,
|
||||
outcome.runnerup_message,
|
||||
)
|
||||
self._phone_check_result = (
|
||||
outcome.status,
|
||||
(
|
||||
f"StrongLifts: {outcome.phone_message} · "
|
||||
f"RunnerUp: {outcome.runnerup_message}"
|
||||
),
|
||||
f"{g.LIFT}{g.FAIL} {g.RUN}{g.FAIL}",
|
||||
)
|
||||
self.render(gather_status())
|
||||
|
||||
def _credit_result_lines(
|
||||
self, header: str, credit: WorkoutCreditResult
|
||||
) -> list[str]:
|
||||
"""Human-readable outcome lines for an applied workout credit.
|
||||
"""Glyph outcome lines for an applied workout credit (no words).
|
||||
|
||||
Shared by the "Check Phone" verify path and the "Log Manual Workout"
|
||||
path so both report the reward identically: the base +2h shutdown push
|
||||
@ -223,12 +230,11 @@ class PhoneCheckMixin:
|
||||
"""
|
||||
lines = [header]
|
||||
if credit.already_counted_today:
|
||||
lines.append("(Already recorded today — no additional credit.)")
|
||||
# Was "(Already recorded today — no additional credit.)".
|
||||
lines.append(f"{g.OK}{g.OK} {g.GIFT}0")
|
||||
return lines
|
||||
if credit.shutdown_adjusted:
|
||||
lines.append("Shutdown time +2h later!")
|
||||
lines.append(f"{g.SHUTDOWN} {g.GIFT}{span(120)}")
|
||||
if credit.extra_bonus_delta > 0:
|
||||
lines.append(
|
||||
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight"
|
||||
)
|
||||
lines.append(f"{g.LIFT} {g.GIFT}{span(credit.extra_bonus_delta)}")
|
||||
return lines
|
||||
|
||||
@ -15,8 +15,10 @@ writes to ``log.json``. Host class must provide:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
|
||||
from screen_locker._temperature import HARD_TIMEOUT_SECONDS, submit_background
|
||||
|
||||
@ -25,54 +27,45 @@ if TYPE_CHECKING:
|
||||
|
||||
from screen_locker._temperature import TemperatureCheck
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemperatureStatusMixin:
|
||||
"""Provides the background Warsaw-temperature fetch/poll/render."""
|
||||
|
||||
def _section_temperature(self, parent: tk.Widget) -> None:
|
||||
"""Render the live Warsaw temperature.
|
||||
"""Render the live temperature against the heat-skip threshold.
|
||||
|
||||
The same reading the real locker's heat-skip check would see,
|
||||
fetched in the background.
|
||||
fetched in the background. textgate: was "Warsaw Temperature" and a
|
||||
sentence per state; now ``▲`` with the reading (or wait / clock-fail /
|
||||
fail glyphs), danger-coloured with ``↷`` when it would heat-skip.
|
||||
"""
|
||||
del parent
|
||||
self._label("Warsaw Temperature", role="body", pad="sm")
|
||||
result = self._temp_result
|
||||
palette = self._colors.palette
|
||||
if result is None:
|
||||
self._text(
|
||||
"Checking Warsaw temperature…",
|
||||
role="caption",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
self._text(f"{g.HEAT} {g.WAIT}", role="body", color=palette.muted)
|
||||
return
|
||||
if result.timed_out:
|
||||
_logger.warning(
|
||||
"Temperature check timed out after ~%ds", int(HARD_TIMEOUT_SECONDS)
|
||||
)
|
||||
self._text(
|
||||
f"Warsaw temperature check timed out after ~"
|
||||
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
|
||||
role="caption",
|
||||
color=self._colors.palette.warning,
|
||||
f"{g.HEAT} {g.CLOCK}{g.FAIL}", role="body", color=palette.warning
|
||||
)
|
||||
return
|
||||
if result.temp_celsius is None:
|
||||
self._text(
|
||||
"Warsaw temperature check failed (network/API error).",
|
||||
role="caption",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
self._text(f"{g.HEAT} {g.FAIL}", role="body", color=palette.warning)
|
||||
return
|
||||
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
|
||||
relation = "≥" if hot else "<"
|
||||
self._text(
|
||||
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
|
||||
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
|
||||
role="caption",
|
||||
color=self._colors.palette.warning if hot else self._colors.palette.muted,
|
||||
f"{g.HEAT} {result.temp_celsius:.0f}°C {relation} "
|
||||
f"{HEAT_SKIP_TEMP_THRESHOLD}°C" + (f" {g.SKIP}" if hot else ""),
|
||||
role="body",
|
||||
color=palette.danger if hot else palette.muted,
|
||||
)
|
||||
if hot:
|
||||
self._text(
|
||||
"Would trigger heat-skip today.",
|
||||
role="caption",
|
||||
color=self._colors.palette.danger,
|
||||
)
|
||||
|
||||
def _start_temperature_check(self) -> None:
|
||||
"""Submit a background Warsaw-temperature fetch and start polling it.
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
"""UI flow methods mixin for the screen locker."""
|
||||
"""UI flow methods mixin for the screen locker.
|
||||
|
||||
textgate: every screen here is glyphs (``_glyphs``) plus numbers, with the
|
||||
palette colour as the second channel. The sentences these screens used to
|
||||
paint ("No Workout Found", the verifier's reason, the sync diagnosis) go to
|
||||
the log at warning instead, so the reason is never lost, only unpainted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker import _manual_workout
|
||||
from screen_locker._source_health import collect_source_findings, explain_findings
|
||||
from screen_locker._ui_flows_lockout import LockoutFlowMixin
|
||||
@ -14,6 +22,8 @@ from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
"""Mixin providing UI flow logic for the screen locker.
|
||||
@ -28,15 +38,16 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
Split from :meth:`_start_phone_check` so the fit check
|
||||
(``scripts/verify_screen_fits.py``) can measure this screen without
|
||||
starting a background thread that talks to a phone over adb.
|
||||
Was "Checking phone..." / "Looking for today's workout in StrongLifts".
|
||||
"""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Checking phone...",
|
||||
f"{g.PHONE} {g.LIFT}",
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="lg",
|
||||
)
|
||||
self._text("Looking for today's workout in StrongLifts...", role="body")
|
||||
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
|
||||
|
||||
def _start_phone_check(self) -> None:
|
||||
"""Check phone for today's workout immediately at startup."""
|
||||
@ -54,23 +65,39 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
else:
|
||||
self.root.after(500, self._poll_phone_check)
|
||||
|
||||
def _show_retry_and_sick(self, message: str) -> None:
|
||||
"""Show TRY AGAIN, I'm sick and (budget allowing) the manual log."""
|
||||
def _show_retry_and_sick(self, message: str, *, status: str = "") -> None:
|
||||
"""Show retry, I'm sick and (budget allowing) the manual log.
|
||||
|
||||
Was "No Workout Found" over *message*. The screen now shows the
|
||||
failure glyph for *status*; *message* goes to the log.
|
||||
"""
|
||||
_logger.warning("No workout accepted (%s): %s", status or "?", message)
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"No Workout Found",
|
||||
f"{g.LIFT} {g.FAIL}",
|
||||
role="display",
|
||||
color=self._colors.palette.danger,
|
||||
pad="md",
|
||||
)
|
||||
self._text(message, color=self._colors.palette.warning)
|
||||
if status:
|
||||
self._text(
|
||||
g.status_glyph(status),
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
# Never accuse the user of skipping a workout the machine simply could
|
||||
# not see. On 2026-08-24 this screen said "No Workout Found" after a
|
||||
# 1h57m session, because Firebase was unreadable and the GitHub mirror
|
||||
# had been stale for nine days -- neither of which was shown here.
|
||||
diagnosis = explain_findings(collect_source_findings())
|
||||
if diagnosis:
|
||||
self._text(diagnosis, color=self._colors.palette.muted)
|
||||
# Now a sync-fault glyph, one per finding; the prose is logged.
|
||||
findings = collect_source_findings()
|
||||
if findings:
|
||||
_logger.warning("%s", explain_findings(findings))
|
||||
self._text(
|
||||
f"{g.SYNC}{g.FAIL} " * len(findings),
|
||||
role="title",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
self._text(
|
||||
_manual_workout.budget_summary(self.log_file),
|
||||
color=self._colors.palette.muted,
|
||||
@ -78,44 +105,47 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
"TRY AGAIN",
|
||||
g.RETRY,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._start_phone_check,
|
||||
width=12,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._sick_button(frame)
|
||||
if self._mw_budget_closed():
|
||||
self._text(
|
||||
"Manual-workout budget exhausted. No manual-log option available.",
|
||||
color=self._colors.palette.danger,
|
||||
)
|
||||
# Was "Manual-workout budget exhausted. No manual-log option".
|
||||
self._text(f"{g.FORM} {g.CLOSE}", color=self._colors.palette.danger)
|
||||
else:
|
||||
self._button(
|
||||
frame,
|
||||
"Log Manual Workout",
|
||||
g.FORM,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._show_manual_workout_form,
|
||||
width=16,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
def _on_manual_workout_saved(self, entry: dict) -> None:
|
||||
"""Show confirmation and unlock after a manual-workout entry is built."""
|
||||
self.workout_data = entry
|
||||
def _paint_accepted(self, glyph: str) -> None:
|
||||
"""Paint "accepted, unlocking" and schedule the unlock.
|
||||
|
||||
Was "✓ Workout Verified!" / "✓ Run Verified!" / "✓ Manual Workout
|
||||
Logged!" over the source line and "Unlocking...".
|
||||
"""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"✓ Manual Workout Logged!",
|
||||
f"{glyph} {g.OK}",
|
||||
role="display",
|
||||
scale=1.3,
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(
|
||||
entry.get("source", ""), role="subtitle", color=self._colors.palette.success
|
||||
)
|
||||
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
|
||||
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
|
||||
unlock_delay = 1500 if self.demo_mode else 2000
|
||||
self.root.after(unlock_delay, self.unlock_screen)
|
||||
|
||||
def _on_manual_workout_saved(self, entry: dict) -> None:
|
||||
"""Show confirmation and unlock after a manual-workout entry is built."""
|
||||
self.workout_data = entry
|
||||
self._paint_accepted(g.FORM)
|
||||
|
||||
def _on_manual_workout_cancelled(self) -> None:
|
||||
"""Return to the retry screen when the manual-workout form is cancelled."""
|
||||
self._start_phone_check()
|
||||
@ -125,39 +155,13 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
if status == "verified":
|
||||
self.workout_data["type"] = "phone_verified"
|
||||
self.workout_data["source"] = message
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"✓ Workout Verified!",
|
||||
role="display",
|
||||
scale=1.3,
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(message, role="subtitle", color=self._colors.palette.success)
|
||||
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
|
||||
unlock_delay = 1500 if self.demo_mode else 2000
|
||||
self.root.after(unlock_delay, self.unlock_screen)
|
||||
elif status == "too_short":
|
||||
self._show_retry_and_sick(
|
||||
f"❌ {message}\n\n"
|
||||
"Your workout was too short!\n"
|
||||
"Actually do the full workout, don't just\n"
|
||||
"spam through the exercises.",
|
||||
)
|
||||
elif status == "clock_tampered":
|
||||
self._show_retry_and_sick(
|
||||
f"❌ {message}\n\n"
|
||||
"System clock appears to be manipulated.\n"
|
||||
"Fix your system time and try again.",
|
||||
)
|
||||
self._paint_accepted(g.LIFT)
|
||||
elif status in ("too_short", "clock_tampered"):
|
||||
self._show_retry_and_sick(message, status=status)
|
||||
elif status in ("stale", "no_exercises", "not_verified"):
|
||||
# Try RunnerUp before showing failure — user may have run instead of lifted.
|
||||
self._start_runnerup_fallback(
|
||||
lambda: self._show_retry_and_sick(
|
||||
f"❌ {message}\n\n"
|
||||
"Neither StrongLifts nor RunnerUp found a workout today.\n"
|
||||
"Go do your workout first!",
|
||||
)
|
||||
lambda: self._show_retry_and_sick(message, status=status)
|
||||
)
|
||||
else:
|
||||
# no_phone or error — try RunnerUp first, then penalty timer.
|
||||
@ -168,15 +172,16 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
|
||||
Shows a waiting screen, runs the check in a background thread, then
|
||||
either unlocks (run verified) or calls ``on_failure``.
|
||||
Was "Checking RunnerUp..." / "Looking for today's run in RunnerUp".
|
||||
"""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Checking RunnerUp...",
|
||||
f"{g.PHONE} {g.RUN}",
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="lg",
|
||||
)
|
||||
self._text("Looking for today's run in RunnerUp...", role="body")
|
||||
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
self._runnerup_future = executor.submit(self._verify_runnerup_workout)
|
||||
executor.shutdown(wait=False)
|
||||
@ -190,20 +195,7 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
if status == "verified":
|
||||
self.workout_data["type"] = "runnerup_verified"
|
||||
self.workout_data["source"] = message
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"✓ Run Verified!",
|
||||
role="display",
|
||||
scale=1.3,
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(message, role="subtitle", color=self._colors.palette.success)
|
||||
self._text(
|
||||
"Unlocking...", role="body", color=self._colors.palette.muted
|
||||
)
|
||||
unlock_delay = 1500 if self.demo_mode else 2000
|
||||
self.root.after(unlock_delay, self.unlock_screen)
|
||||
self._paint_accepted(g.RUN)
|
||||
else:
|
||||
self._runnerup_on_failure()
|
||||
else:
|
||||
|
||||
@ -7,8 +7,10 @@ Split out of :mod:`screen_locker._ui_flows` to keep every file under the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._constants import (
|
||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS,
|
||||
PHONE_PENALTY_DELAY_DEMO,
|
||||
@ -18,6 +20,8 @@ from screen_locker._constants import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LockoutFlowMixin:
|
||||
"""Lockout countdown and the no-phone penalty timer."""
|
||||
@ -27,10 +31,14 @@ class LockoutFlowMixin:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lockout(self) -> None:
|
||||
"""Display lockout screen with countdown timer."""
|
||||
"""Display lockout screen with countdown timer.
|
||||
|
||||
Was "Go work out! Locked for N seconds": the barbell in the danger
|
||||
colour over the seconds counting down.
|
||||
"""
|
||||
self.clear_container()
|
||||
self.lockout_label = self._label(
|
||||
f"Go work out!\nLocked for {self.lockout_time} seconds",
|
||||
g.LIFT,
|
||||
role="display",
|
||||
scale=1.5,
|
||||
color=self._colors.palette.danger,
|
||||
@ -61,7 +69,13 @@ class LockoutFlowMixin:
|
||||
def _show_phone_penalty(
|
||||
self, message: str, *, on_done: Callable[[], None] | None = None
|
||||
) -> None:
|
||||
"""Show penalty countdown when phone verification is unavailable."""
|
||||
"""Show penalty countdown when phone verification is unavailable.
|
||||
|
||||
Was "Cannot Verify Workout", the reason, and "Connect phone via ADB
|
||||
to skip this wait, or wait for the penalty timer". Now the phone glyph
|
||||
crossed out over the seconds left; the reason goes to the log.
|
||||
"""
|
||||
_logger.warning("Phone check unavailable, penalty wait: %s", message)
|
||||
self.clear_container()
|
||||
self._phone_penalty_done_fn: Callable[[], None] = (
|
||||
on_done
|
||||
@ -80,18 +94,11 @@ class LockoutFlowMixin:
|
||||
else base_delay + NO_PHONE_EXTRA_LOCKOUT_SECONDS
|
||||
)
|
||||
self._label(
|
||||
"Cannot Verify Workout",
|
||||
f"{g.PHONE}{g.FAIL}",
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="md",
|
||||
)
|
||||
self._text(message, color=self._colors.palette.warning)
|
||||
self._text(
|
||||
"Connect phone via ADB to skip this wait,\n"
|
||||
"or wait for the penalty timer.\n\n"
|
||||
"Note: Phone must be rooted and StrongLifts installed.",
|
||||
role="body",
|
||||
)
|
||||
self.phone_penalty_remaining = delay
|
||||
self.phone_penalty_label = self._label(
|
||||
str(delay),
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
"""Relaxed-day (Tue/Wed/Thu) UI flow methods mixin."""
|
||||
"""Relaxed-day (Tue/Wed/Thu) UI flow methods mixin.
|
||||
|
||||
textgate: glyphs and numbers only (``_glyphs``); the verifier's reason goes
|
||||
to the log, not the screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import logging
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._weekly_check import (
|
||||
RELAXED_DAY_SKIP_TYPE,
|
||||
WEEKLY_WORKOUT_MINIMUM,
|
||||
count_weekly_workouts,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UIFlowsRelaxedMixin:
|
||||
"""Mixin providing the relaxed-day UI flow logic."""
|
||||
@ -23,36 +31,39 @@ class UIFlowsRelaxedMixin:
|
||||
|
||||
The screen is not locked — the user can skip freely or voluntarily
|
||||
import a Stronglift workout that counts toward the weekly minimum.
|
||||
Was "Optional Day (Tue / Wed / Thu)" / "Weekly workouts: N / M. No
|
||||
penalty for skipping today." / "Skip — No Penalty" / "Log Stronglift
|
||||
Workout": now the optional-day diamond, the week as dots + numbers,
|
||||
skip (green) and the barbell (go).
|
||||
"""
|
||||
count = count_weekly_workouts(self.log_file)
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Optional Day (Tue / Wed / Thu)",
|
||||
role="title",
|
||||
g.OPTIONAL,
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="md",
|
||||
)
|
||||
self._text(
|
||||
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
|
||||
"No penalty for skipping today.",
|
||||
role="subtitle",
|
||||
g.dots(count, WEEKLY_WORKOUT_MINIMUM),
|
||||
role="title",
|
||||
color=self._colors.palette.muted,
|
||||
pad="sm",
|
||||
)
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
"Skip — No Penalty",
|
||||
g.SKIP,
|
||||
bg=self._colors.palette.success,
|
||||
command=self._skip_relaxed_day,
|
||||
width=18,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
frame,
|
||||
"Log Stronglift Workout",
|
||||
f"{g.GO} {g.LIFT}",
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._start_relaxed_phone_check,
|
||||
width=20,
|
||||
width=8,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
# Skipping is free here, but a sick day is more than a skip: it
|
||||
# stands every gate down and lifts tonight's shutdown to 20:00.
|
||||
@ -74,12 +85,12 @@ class UIFlowsRelaxedMixin:
|
||||
"""Run Stronglift check in relaxed mode (no screen grab)."""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Checking phone...",
|
||||
f"{g.PHONE} {g.LIFT}",
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="lg",
|
||||
)
|
||||
self._text("Looking for today's workout in StrongLifts...", role="body")
|
||||
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
self._phone_future = executor.submit(self._verify_phone_workout)
|
||||
executor.shutdown(wait=False)
|
||||
@ -108,30 +119,34 @@ class UIFlowsRelaxedMixin:
|
||||
self._show_relaxed_retry(message, status)
|
||||
|
||||
def _show_relaxed_retry(self, message: str, status: str) -> None:
|
||||
"""Show retry and skip-close when workout not found in relaxed mode."""
|
||||
"""Show retry and skip-close when workout not found in relaxed mode.
|
||||
|
||||
Was "No Workout Found" / the reason / "TRY AGAIN" / "Close (Skip)".
|
||||
"""
|
||||
_logger.warning("Relaxed-day check found no workout (%s): %s", status, message)
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"No Workout Found",
|
||||
f"{g.LIFT} {g.FAIL}",
|
||||
role="display",
|
||||
color=self._colors.palette.danger,
|
||||
pad="md",
|
||||
)
|
||||
self._text(
|
||||
f"❌ {message}\n\nReason: {status}", color=self._colors.palette.warning
|
||||
g.status_glyph(status), role="display", color=self._colors.palette.warning
|
||||
)
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
"TRY AGAIN",
|
||||
g.RETRY,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._start_relaxed_phone_check,
|
||||
width=12,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._sick_button(frame)
|
||||
self._button(
|
||||
frame,
|
||||
"Close (Skip)",
|
||||
g.SKIP,
|
||||
bg=self._colors.palette.success,
|
||||
command=self.close,
|
||||
width=14,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
@ -23,8 +23,10 @@ Split out of :mod:`screen_locker._ui_flows` to keep every file under the
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._constants import SHUTDOWN_BASE_FILE
|
||||
from screen_locker._credit_slot import locked_shutdown_state
|
||||
from screen_locker._sick import mark_today_sick, push_in_background
|
||||
@ -32,18 +34,20 @@ from screen_locker._sick import mark_today_sick, push_in_background
|
||||
if TYPE_CHECKING:
|
||||
import tkinter as tk
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SickDayFlowMixin:
|
||||
"""The one-tap sick-day button and what follows it."""
|
||||
|
||||
def _sick_button(self, frame: tk.Widget) -> None:
|
||||
"""Pack the "I'm sick" button into ``frame``."""
|
||||
"""Pack the sick-day button (was "I'm sick") into ``frame``."""
|
||||
self._button(
|
||||
frame,
|
||||
"I'm sick",
|
||||
g.SICK,
|
||||
bg=self._colors.palette.warning,
|
||||
command=self.mark_sick_and_unlock,
|
||||
width=12,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
# The keep-aware alternative (``_ui_flows_sick_panel``), next to it.
|
||||
self._choose_lockers_link(frame)
|
||||
@ -78,15 +82,19 @@ class SickDayFlowMixin:
|
||||
self.close()
|
||||
|
||||
def _show_sick_refused(self, message: str) -> None:
|
||||
"""Say why today could not be marked sick; the lock stays up."""
|
||||
"""Show that today could not be marked sick; the lock stays up.
|
||||
|
||||
Was "Could not mark today as sick" over sickdays' reason; the reason
|
||||
(a free day, an unreadable pool) now goes to the log.
|
||||
"""
|
||||
_logger.warning("Could not mark today sick: %s", message)
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Could not mark today as sick",
|
||||
f"{g.SICK} {g.FAIL}",
|
||||
role="display",
|
||||
color=self._colors.palette.danger,
|
||||
pad="md",
|
||||
)
|
||||
self._text(message, color=self._colors.palette.warning)
|
||||
frame = self._button_row()
|
||||
back = (
|
||||
self._start_relaxed_day_flow
|
||||
@ -95,8 +103,8 @@ class SickDayFlowMixin:
|
||||
)
|
||||
self._button(
|
||||
frame,
|
||||
"Back",
|
||||
g.BACK,
|
||||
bg=self._colors.palette.accent,
|
||||
command=back,
|
||||
width=12,
|
||||
width=4,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
@ -29,6 +29,7 @@ from gatelock import bind_activate
|
||||
import sickdays
|
||||
from sickdays.gui import KeepPanel
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._surface_group import FrameGroup
|
||||
|
||||
@ -39,8 +40,10 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
LINK_TEXT = "choose lockers…"
|
||||
KEPT_NOTE = "Workout lock kept — finish the workout to unlock."
|
||||
# textgate: was "choose lockers…" (sick, then pick) and "Workout lock kept —
|
||||
# finish the workout to unlock." (sick, but the barbell lock stays).
|
||||
LINK_TEXT = f"{g.SICK}▾"
|
||||
KEPT_NOTE = f"{g.SICK} {g.LIFT} {g.LOCK}"
|
||||
|
||||
|
||||
def build_sick_panel(
|
||||
@ -162,10 +165,10 @@ class SickPanelFlowMixin:
|
||||
).pack(pady=self._colors.space("md"))
|
||||
back = self._button(
|
||||
box,
|
||||
"Back",
|
||||
g.BACK,
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._close_sick_panel,
|
||||
width=12,
|
||||
width=4,
|
||||
)
|
||||
back.pack(pady=self._colors.space("sm"))
|
||||
back.first.focus_set()
|
||||
|
||||
@ -3,10 +3,16 @@
|
||||
Split out of ``screen_lock`` so that module stays inside the repo's 400-line
|
||||
cap; this is pure view code (paint the credit, then close), which is exactly
|
||||
the kind of thing the other ``_ui_*`` mixins already own.
|
||||
|
||||
textgate: was "Great job! 💪" / "Shutdown time +2h later! 🎁" / "Extra
|
||||
workout today! +1h tonight" / "🔥 N-week streak (5+ workouts each)" /
|
||||
"Screen Unlocked!". Now: the open lock in green, then each reward as its
|
||||
glyph and amount, then the streak as week bars and their count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._constants import EXTRA_BENEFITS_FILE
|
||||
from screen_locker._earned import span
|
||||
from screen_locker._extra_benefits import current_streak
|
||||
@ -18,12 +24,12 @@ class UnlockViewMixin:
|
||||
"""Paints the unlock screen, then closes it."""
|
||||
|
||||
def unlock_screen(self) -> None:
|
||||
"""Apply workout credit and display the success message."""
|
||||
"""Apply workout credit and display the success glyphs."""
|
||||
credit = self._apply_workout_credit()
|
||||
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Great job! 💪",
|
||||
f"{g.UNLOCK} {g.OK}",
|
||||
role="display",
|
||||
scale=1.5,
|
||||
color=self._colors.palette.success,
|
||||
@ -31,22 +37,21 @@ class UnlockViewMixin:
|
||||
)
|
||||
if credit.shutdown_adjusted:
|
||||
self._text(
|
||||
"Shutdown time +2h later! 🎁",
|
||||
f"{g.SHUTDOWN} {g.GIFT}{span(120)}",
|
||||
role="title",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
if credit.extra_bonus_delta > 0:
|
||||
self._text(
|
||||
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight",
|
||||
f"{g.LIFT} {g.GIFT}{span(credit.extra_bonus_delta)}",
|
||||
role="subtitle",
|
||||
color=self._colors.palette.warning,
|
||||
)
|
||||
streak = current_streak(EXTRA_BENEFITS_FILE)
|
||||
if streak >= 1:
|
||||
self._text(
|
||||
f"🔥 {streak}-week streak (5+ workouts each)",
|
||||
f"{g.STREAK * min(streak, 10)} {streak}",
|
||||
role="label",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
self._text("Screen Unlocked!", role="display", pad="md")
|
||||
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
||||
|
||||
@ -30,6 +30,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import LockConfig
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||
from screen_locker._status_data import (
|
||||
StatusSnapshot,
|
||||
@ -112,7 +113,7 @@ class StatusWindow(
|
||||
"""Redraw the whole window from *snapshot*."""
|
||||
self._last_snapshot = snapshot
|
||||
self.clear_container()
|
||||
self._label("Workout Status", role="title", pad="md")
|
||||
# textgate: the "Workout Status" heading is gone; see the sections.
|
||||
self._section_today(self.container, snapshot.today)
|
||||
self._section_week(self.container, snapshot.week)
|
||||
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
|
||||
@ -130,44 +131,29 @@ class StatusWindow(
|
||||
if status == "verified"
|
||||
else self._colors.palette.warning
|
||||
)
|
||||
self._text(f"Phone check ({status}): {message}", role="body", color=color)
|
||||
self._text(message, role="title", color=color)
|
||||
if self._credit_message is not None:
|
||||
self._text(
|
||||
self._credit_message, role="body", color=self._colors.palette.success
|
||||
)
|
||||
# "Check Phone"/"Log Manual Workout" are the primary write actions
|
||||
# (accent, high contrast per rule 3); "Refresh"/"Close" are secondary
|
||||
# utility actions (muted) -- two tiers instead of four arbitrary hues.
|
||||
# Check phone / manual log are the primary write actions (accent,
|
||||
# high contrast per rule 3); refresh / close are secondary utility
|
||||
# actions (muted) -- two tiers instead of four arbitrary hues. Was
|
||||
# "Check Phone" / "Log Manual Workout" / "Refresh" / "Close".
|
||||
frame = self._button_row()
|
||||
self._button(
|
||||
frame,
|
||||
"Check Phone",
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._on_check_phone_clicked,
|
||||
width=14,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
buttons = [(g.PHONE, self._colors.palette.accent, self._on_check_phone_clicked)]
|
||||
if not self._mw_budget_closed():
|
||||
self._button(
|
||||
frame,
|
||||
"Log Manual Workout",
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._show_manual_workout_form,
|
||||
width=16,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
frame,
|
||||
"Refresh",
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self._on_refresh_clicked,
|
||||
width=10,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
frame,
|
||||
"Close",
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self.root.destroy,
|
||||
width=8,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
buttons.append(
|
||||
(g.FORM, self._colors.palette.accent, self._show_manual_workout_form)
|
||||
)
|
||||
buttons += [
|
||||
(g.RETRY, self._colors.palette.field_bg, self._on_refresh_clicked),
|
||||
(g.CLOSE, self._colors.palette.field_bg, self.root.destroy),
|
||||
]
|
||||
for glyph, bg, command in buttons:
|
||||
self._button(frame, glyph, bg=bg, command=command, width=4).pack(
|
||||
side="left", padx=self._colors.space("sm")
|
||||
)
|
||||
|
||||
def _on_refresh_clicked(self) -> None:
|
||||
"""Clear any stale phone-check/credit/temperature results, re-check both."""
|
||||
@ -192,9 +178,7 @@ class StatusWindow(
|
||||
verifier.workout_data = entry
|
||||
credit = verifier._apply_workout_credit()
|
||||
self._credit_message = "\n".join(
|
||||
self._credit_result_lines(
|
||||
f"Manual workout logged: {entry.get('source', '')}", credit
|
||||
)
|
||||
self._credit_result_lines(f"{g.FORM} {g.OK}", credit)
|
||||
)
|
||||
self.render(gather_status())
|
||||
|
||||
|
||||
@ -18,6 +18,10 @@ import pytest
|
||||
from screen_locker import _credit_notify
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
# The toast's glyphs (textgate): the module's own constants, not words.
|
||||
_SHUTDOWN = _credit_notify._SHUTDOWN
|
||||
_GAMING = _credit_notify._GAMING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
@ -64,28 +68,30 @@ class TestCreditBody:
|
||||
def test_full_body(self) -> None:
|
||||
"""Shutdown time, tonight's move and the gaming budget."""
|
||||
body = _credit_notify.credit_body((0, 1200, 0), (0, 1320, 0), 420)
|
||||
assert body == "Shutdown 22:00 (+2h) \N{MIDDLE DOT} gaming 7.0h"
|
||||
assert body == f"{_SHUTDOWN} 22:00 (+2h) \N{MIDDLE DOT} {_GAMING} 7.0h"
|
||||
|
||||
def test_a_capped_credit_shows_plus_zero(self) -> None:
|
||||
"""No backwards move is ever shown: the delta floors at zero."""
|
||||
body = _credit_notify.credit_body((0, 1380, 0), (0, 1320, 0), None)
|
||||
assert body == "Shutdown 22:00 (+0h)"
|
||||
assert body == f"{_SHUTDOWN} 22:00 (+0h)"
|
||||
|
||||
def test_unknown_before_drops_the_delta(self) -> None:
|
||||
"""Without the old config there is no move to show."""
|
||||
assert _credit_notify.credit_body(None, (0, 1320, 0), None) == "Shutdown 22:00"
|
||||
assert (
|
||||
_credit_notify.credit_body(None, (0, 1320, 0), None) == f"{_SHUTDOWN} 22:00"
|
||||
)
|
||||
|
||||
def test_unreadable_after_warns_and_shows_gaming_only(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unreadable config is said out loud, not silently omitted."""
|
||||
assert _credit_notify.credit_body(None, None, 90) == "gaming 1.5h"
|
||||
assert _credit_notify.credit_body(None, None, 90) == f"{_GAMING} 1.5h"
|
||||
assert _warned(caplog, "unreadable")
|
||||
|
||||
def test_nothing_known_still_says_something(self) -> None:
|
||||
"""An empty body would read as a glitch; say the credit happened."""
|
||||
body = _credit_notify.credit_body(None, None, None)
|
||||
assert body == "Credit applied (details unavailable)"
|
||||
assert body == "\N{CHECK MARK}"
|
||||
|
||||
|
||||
class TestSendNotification:
|
||||
@ -151,7 +157,7 @@ def test_notify_workout_credit_sends_the_composed_body() -> None:
|
||||
):
|
||||
assert _real_notify((0, 1200, 0), (0, 1260, 0), None)
|
||||
send.assert_called_once_with(
|
||||
_credit_notify.TITLE, "Shutdown 21:00 (+1h) \N{MIDDLE DOT} gaming 1.0h"
|
||||
_credit_notify.TITLE, f"{_SHUTDOWN} 21:00 (+1h) \N{MIDDLE DOT} {_GAMING} 1.0h"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -163,8 +163,11 @@ class TestBudgetSummary:
|
||||
log_file = tmp_path / "log.json"
|
||||
_write_logs(log_file, {"2026-07-04": "manual_workout"})
|
||||
summary = budget_summary(log_file, today=_TODAY)
|
||||
assert "Manual:" in summary
|
||||
assert "1/" in summary
|
||||
# Glyph + used/7-day budget · used/30-day budget, no words.
|
||||
assert summary == (
|
||||
f"≡ 1/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS} · "
|
||||
f"1/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}"
|
||||
)
|
||||
|
||||
|
||||
_TT_DEFAULT = ManualWorkoutDraft(
|
||||
|
||||
@ -8,13 +8,19 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._manual_schedule import ScheduleResult
|
||||
from screen_locker._manual_workout_date import BACK_GLYPH, SCHEDULED_GLYPH
|
||||
from screen_locker._manual_workout_date import (
|
||||
BACK_GLYPH,
|
||||
REJECTED_GLYPH,
|
||||
SCHEDULED_GLYPH,
|
||||
)
|
||||
from screen_locker.tests import test_manual_workout_dialog_part2 as dialog_tests
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD = "screen_locker._manual_workout_date"
|
||||
|
||||
|
||||
@ -33,6 +39,11 @@ def _locker(mock_tk: MagicMock, tmp_path: Path, day: str) -> MagicMock:
|
||||
return locker
|
||||
|
||||
|
||||
def _warned(caplog: pytest.LogCaptureFixture) -> list[str]:
|
||||
"""Every WARNING message logged during the test."""
|
||||
return [r.getMessage() for r in caplog.records if r.levelname == "WARNING"]
|
||||
|
||||
|
||||
class TestBudgetClosed:
|
||||
"""The button gate reads the local ledger only (no network)."""
|
||||
|
||||
@ -67,25 +78,35 @@ class TestHandledByDate:
|
||||
"""An unparsable date shows an error and schedules nothing."""
|
||||
locker = _locker(mock_tk, tmp_path, "soon")
|
||||
assert locker._mw_handled_by_date(MagicMock()) is True
|
||||
locker._mw_error_label.config.assert_called_once()
|
||||
locker._mw_error_label.config.assert_called_once_with(text=REJECTED_GLYPH)
|
||||
locker._show_manual_workout_scheduled.assert_not_called()
|
||||
|
||||
def test_a_past_date_shows_the_error(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A date before today is rejected with a message mentioning the past."""
|
||||
"""A past date shows the fail mark; the reason (the past) is logged."""
|
||||
locker = _locker(mock_tk, tmp_path, _plus(-1))
|
||||
assert locker._mw_handled_by_date(MagicMock()) is True
|
||||
assert "past" in locker._mw_error_label.config.call_args.kwargs["text"]
|
||||
locker._mw_error_label.config.assert_called_once_with(text=REJECTED_GLYPH)
|
||||
assert any("past" in m for m in _warned(caplog))
|
||||
|
||||
def test_today_over_budget_shows_the_error(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Today's date with no budget left shows the budget error and is handled."""
|
||||
locker = _locker(mock_tk, tmp_path, today_str())
|
||||
with patch(f"{_MOD}.budget_error", return_value="budget full"):
|
||||
assert locker._mw_handled_by_date(MagicMock()) is True
|
||||
locker._mw_error_label.config.assert_called_once_with(text="budget full")
|
||||
locker._mw_error_label.config.assert_called_once_with(text=REJECTED_GLYPH)
|
||||
assert any("budget full" in m for m in _warned(caplog))
|
||||
|
||||
def test_today_within_budget_is_left_to_the_host(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -97,14 +118,19 @@ class TestHandledByDate:
|
||||
locker._mw_error_label.config.assert_not_called()
|
||||
|
||||
def test_a_refused_future_entry_shows_why(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A future entry the scheduler refuses shows its reason and no confirmation."""
|
||||
"""A refused future entry shows the fail mark, logs why, confirms nothing."""
|
||||
locker = _locker(mock_tk, tmp_path, _plus(2))
|
||||
refused = ScheduleResult(scheduled=False, record_id="r", reason="no room")
|
||||
with patch(f"{_MOD}.schedule_future_workout", return_value=refused):
|
||||
assert locker._mw_handled_by_date(MagicMock()) is True
|
||||
locker._mw_error_label.config.assert_called_once_with(text="no room")
|
||||
locker._mw_error_label.config.assert_called_once_with(text=REJECTED_GLYPH)
|
||||
assert any("no room" in m for m in _warned(caplog))
|
||||
locker._show_manual_workout_scheduled.assert_not_called()
|
||||
|
||||
def test_a_scheduled_future_entry_shows_the_confirmation(
|
||||
@ -117,7 +143,7 @@ class TestHandledByDate:
|
||||
with patch(f"{_MOD}.schedule_future_workout", return_value=done) as schedule:
|
||||
assert locker._mw_handled_by_date(draft) is True
|
||||
schedule.assert_called_once_with(locker.log_file, draft, _plus(2))
|
||||
locker._show_manual_workout_scheduled.assert_called_once_with("scheduled")
|
||||
locker._show_manual_workout_scheduled.assert_called_once_with(_plus(2))
|
||||
locker._mw_error_label.config.assert_not_called()
|
||||
|
||||
|
||||
@ -135,7 +161,7 @@ class TestScheduledConfirmation:
|
||||
object.__setattr__(locker, "_on_manual_workout_cancelled", cancelled)
|
||||
mock_tk.Button.reset_mock()
|
||||
|
||||
locker._show_manual_workout_scheduled("scheduled for tomorrow")
|
||||
locker._show_manual_workout_scheduled("2026-11-13")
|
||||
|
||||
buttons = {
|
||||
c.kwargs.get("text"): c.kwargs for c in mock_tk.Button.call_args_list
|
||||
@ -146,7 +172,7 @@ class TestScheduledConfirmation:
|
||||
saved.assert_not_called()
|
||||
texts = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
||||
assert SCHEDULED_GLYPH in texts
|
||||
assert any("scheduled for tomorrow" in t for t in texts)
|
||||
assert "2026-11-13" in texts
|
||||
|
||||
|
||||
class TestSubmitRoutesThroughTheDateRules:
|
||||
|
||||
@ -6,6 +6,7 @@ import pathlib
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
# The Dart mirror of the budget caps; read at test time so drift on
|
||||
@ -38,7 +39,7 @@ class TestShowRetryAndManualWorkoutBudget:
|
||||
):
|
||||
locker._show_retry_and_sick("nope")
|
||||
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
|
||||
assert "Log Manual Workout" in button_texts
|
||||
assert g.FORM in button_texts
|
||||
|
||||
def test_hides_button_when_budget_exhausted(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -52,9 +53,9 @@ class TestShowRetryAndManualWorkoutBudget:
|
||||
button_texts: set[str] = set()
|
||||
for call in mock_tk.Button.call_args_list:
|
||||
button_texts.add(call.kwargs.get("text", ""))
|
||||
assert "Log Manual Workout" not in button_texts
|
||||
assert g.FORM not in button_texts
|
||||
text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
||||
assert any("Manual-workout budget exhausted" in t for t in text_calls)
|
||||
assert f"{g.FORM} {g.CLOSE}" in text_calls
|
||||
|
||||
|
||||
class TestOnManualWorkoutSaved:
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.tests._status_view_helpers import (
|
||||
_button_texts,
|
||||
@ -31,7 +32,7 @@ class TestSectionManualWorkoutBudget:
|
||||
calls = [
|
||||
c
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if "week" in c.kwargs.get("text", "")
|
||||
if c.kwargs.get("text", "") == f"{g.FORM} 2/2 · 0/5"
|
||||
]
|
||||
assert any(c.kwargs.get("fg") == window._colors.palette.danger for c in calls)
|
||||
|
||||
@ -44,7 +45,7 @@ class TestSectionManualWorkoutBudget:
|
||||
calls = [
|
||||
c
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if "week" in c.kwargs.get("text", "")
|
||||
if c.kwargs.get("text", "") == f"{g.FORM} 0/2 · 0/5"
|
||||
]
|
||||
assert any(c.kwargs.get("fg") == window._colors.palette.muted for c in calls)
|
||||
|
||||
@ -57,7 +58,7 @@ class TestManualWorkoutButtonVisibility:
|
||||
) -> None:
|
||||
"""The Log Manual Workout button appears while budget remains."""
|
||||
_make_window(mock_tk, _snapshot(), log_file=temp_log_file)
|
||||
assert "Log Manual Workout" in _button_texts(mock_tk)
|
||||
assert g.FORM in _button_texts(mock_tk)
|
||||
|
||||
def test_hidden_when_budget_exhausted(
|
||||
self, mock_tk: MagicMock, temp_log_file: Path
|
||||
@ -65,7 +66,7 @@ class TestManualWorkoutButtonVisibility:
|
||||
"""The Log Manual Workout button is hidden once the budget is closed."""
|
||||
with patch(_CLOSED, return_value=True):
|
||||
_make_window(mock_tk, _snapshot(), log_file=temp_log_file)
|
||||
assert "Log Manual Workout" not in _button_texts(mock_tk)
|
||||
assert g.FORM not in _button_texts(mock_tk)
|
||||
|
||||
|
||||
class TestOnManualWorkoutSaved:
|
||||
@ -102,8 +103,7 @@ class TestOnManualWorkoutSaved:
|
||||
fake_verifier._apply_workout_credit.assert_called_once()
|
||||
message = window._credit_message
|
||||
assert message is not None
|
||||
assert "table tennis at Solec" in message
|
||||
assert "Shutdown time +2h later!" in message
|
||||
assert message.splitlines() == [f"{g.FORM} {g.OK}", f"{g.SHUTDOWN} +2h"]
|
||||
|
||||
def test_shows_extra_bonus_line_and_no_debt(
|
||||
self, mock_tk: MagicMock, temp_log_file: Path
|
||||
@ -127,13 +127,12 @@ class TestOnManualWorkoutSaved:
|
||||
|
||||
message = window._credit_message
|
||||
assert message is not None
|
||||
assert "Extra workout today! +1h tonight" in message
|
||||
assert "debt" not in message.lower()
|
||||
assert message.splitlines() == [f"{g.FORM} {g.OK}", f"{g.LIFT} +1h"]
|
||||
|
||||
def test_already_counted_today_shows_no_extra_credit_note(
|
||||
self, mock_tk: MagicMock, temp_log_file: Path
|
||||
) -> None:
|
||||
"""A second workout the same day says no additional credit was given."""
|
||||
"""A second workout the same day shows a zero gift and no reward line."""
|
||||
credit = WorkoutCreditResult(
|
||||
shutdown_adjusted=False,
|
||||
extra_bonus_delta=0,
|
||||
@ -152,8 +151,7 @@ class TestOnManualWorkoutSaved:
|
||||
|
||||
message = window._credit_message
|
||||
assert message is not None
|
||||
assert "no additional credit" in message
|
||||
assert "Shutdown time" not in message
|
||||
assert message.splitlines() == [f"{g.FORM} {g.OK}", f"{g.OK}{g.OK} +0"]
|
||||
|
||||
|
||||
class TestOnManualWorkoutCancelled:
|
||||
|
||||
@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker.tests._unlock_helpers import setup_unlock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -49,7 +50,7 @@ class TestUnlockScreenExtras:
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""extra_bonus_delta > 0 → _text called with 'Extra workout'."""
|
||||
"""extra_bonus_delta > 0 → _text shows the barbell and the +1h gift."""
|
||||
locker = setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
|
||||
|
||||
# Simulate before=22:00, after=23:00 → delta=60
|
||||
@ -77,7 +78,7 @@ class TestUnlockScreenExtras:
|
||||
):
|
||||
locker.unlock_screen()
|
||||
|
||||
assert any("Extra workout" in c for c in text_calls)
|
||||
assert f"{g.LIFT} {g.GIFT}1h" in text_calls
|
||||
|
||||
def test_no_extra_bonus_when_adjust_fails(
|
||||
self,
|
||||
|
||||
@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker.tests._locker_factories import create_locker
|
||||
from screen_locker.tests._unlock_helpers import setup_unlock
|
||||
|
||||
@ -51,7 +52,7 @@ class TestUnlockScreenRewards:
|
||||
):
|
||||
locker.unlock_screen()
|
||||
|
||||
assert any("streak" in c.lower() for c in text_calls)
|
||||
assert f"{g.STREAK * 3} 3" in text_calls
|
||||
|
||||
def test_extra_bonus_skipped_when_old_cfg_none(
|
||||
self,
|
||||
|
||||
@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
|
||||
import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker import _sick, _ui_flows_sick
|
||||
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
||||
from screen_locker._compliance_predicates import is_sick_day_today
|
||||
@ -20,6 +21,8 @@ from screen_locker.tests.conftest import create_locker
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _button_texts(mock_tk: MagicMock) -> list[str]:
|
||||
"""Return the text of every Button created on the mocked tk module."""
|
||||
@ -155,7 +158,7 @@ class TestButtonEverywhere:
|
||||
"""The retry screen shows the I'm sick button."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._show_retry_and_sick("nope")
|
||||
assert "I'm sick" in _button_texts(mock_tk)
|
||||
assert g.SICK in _button_texts(mock_tk)
|
||||
|
||||
def test_phone_penalty_screen_offers_it_without_waiting(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -163,7 +166,7 @@ class TestButtonEverywhere:
|
||||
"""The phone penalty screen shows I'm sick immediately."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._show_phone_penalty("no phone")
|
||||
assert "I'm sick" in _button_texts(mock_tk)
|
||||
assert g.SICK in _button_texts(mock_tk)
|
||||
|
||||
def test_relaxed_prompt_and_retry_offer_it(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -171,10 +174,10 @@ class TestButtonEverywhere:
|
||||
"""Both the relaxed-day prompt and its retry screen show I'm sick."""
|
||||
locker = create_locker_relaxed_day(mock_tk, tmp_path)
|
||||
locker._start_relaxed_day_flow()
|
||||
assert "I'm sick" in _button_texts(mock_tk)
|
||||
assert g.SICK in _button_texts(mock_tk)
|
||||
mock_tk.Button.reset_mock()
|
||||
locker._show_relaxed_retry("nothing", "no_workout")
|
||||
assert "I'm sick" in _button_texts(mock_tk)
|
||||
assert g.SICK in _button_texts(mock_tk)
|
||||
|
||||
|
||||
class TestTap:
|
||||
@ -200,9 +203,13 @@ class TestTap:
|
||||
close.assert_called_once_with()
|
||||
|
||||
def test_refusal_stays_locked_and_says_why(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A refused tap keeps the lock, shows the reason and offers a Back button."""
|
||||
"""A refused tap keeps the lock, shows sick-fail, logs why, offers back."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
close = MagicMock()
|
||||
object.__setattr__(locker, "close", close)
|
||||
@ -214,9 +221,15 @@ class TestTap:
|
||||
locker.mark_sick_and_unlock()
|
||||
close.assert_not_called()
|
||||
push.assert_not_called()
|
||||
assert "today is a free day" in _label_texts(mock_tk)
|
||||
assert f"{g.SICK} {g.FAIL}" in _label_texts(mock_tk)
|
||||
assert "today is a free day" not in _label_texts(mock_tk)
|
||||
assert any(
|
||||
"today is a free day" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelname == "WARNING"
|
||||
)
|
||||
back = next(
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == g.BACK
|
||||
)
|
||||
assert [back.kwargs["command"]] == [locker._start_phone_check]
|
||||
|
||||
@ -227,6 +240,6 @@ class TestTap:
|
||||
locker = create_locker_relaxed_day(mock_tk, tmp_path)
|
||||
locker._show_sick_refused("nope")
|
||||
back = next(
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == g.BACK
|
||||
)
|
||||
assert [back.kwargs["command"]] == [locker._start_relaxed_day_flow]
|
||||
|
||||
@ -13,6 +13,7 @@ import pytest
|
||||
import sickdays
|
||||
import sickdays.gui._panel
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker import _ui_flows_sick, _ui_flows_sick_panel
|
||||
from screen_locker._ui_flows_sick_panel import LINK_TEXT, build_sick_panel
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
@ -97,15 +98,15 @@ class TestLink:
|
||||
def test_the_link_sits_next_to_the_one_tap_button(
|
||||
self, mock_tk: MagicMock, locker: ScreenLocker
|
||||
) -> None:
|
||||
"""The link is wired to open_sick_panel and appears right after the "I'm sick" button."""
|
||||
"""The link is wired to open_sick_panel and sits right after the sick button."""
|
||||
locker._show_retry_and_sick("nope")
|
||||
buttons = mock_tk.Button.call_args_list
|
||||
texts = _texts(mock_tk.Button)
|
||||
sick = next(c for c in buttons if c.kwargs.get("text") == "I'm sick")
|
||||
sick = next(c for c in buttons if c.kwargs.get("text") == g.SICK)
|
||||
link = next(c for c in buttons if c.kwargs.get("text") == LINK_TEXT)
|
||||
assert sick.kwargs["command"] == locker.mark_sick_and_unlock
|
||||
assert link.kwargs["command"] == locker.open_sick_panel
|
||||
assert texts.index(LINK_TEXT) == texts.index("I'm sick") + 1
|
||||
assert texts.index(LINK_TEXT) == texts.index(g.SICK) + 1
|
||||
|
||||
def test_opening_builds_todays_panel_on_a_backdrop(
|
||||
self, locker: ScreenLocker, panels: list[FakePanel]
|
||||
|
||||
@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import sickdays
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker import _ui_flows_sick
|
||||
from screen_locker._ui_flows_sick_panel import KEPT_NOTE
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
@ -75,7 +76,7 @@ class TestSaved:
|
||||
assert KEPT_NOTE in _texts(mock_tk.Label)
|
||||
mock_tk.Button.return_value.focus_set.assert_called_once_with()
|
||||
back = next(
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == g.BACK
|
||||
)
|
||||
back.kwargs["command"]()
|
||||
assert locker._sick_overlay is None
|
||||
|
||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker.tests._status_view_helpers import (
|
||||
_make_window,
|
||||
_snapshot,
|
||||
@ -14,32 +15,38 @@ if TYPE_CHECKING:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _fg_of(mock_tk: MagicMock, needle: str) -> list[object]:
|
||||
def _fg_of(mock_tk: MagicMock, text: str) -> list[object]:
|
||||
"""fg of every Label whose text is exactly *text*."""
|
||||
return [
|
||||
c.kwargs.get("fg")
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if needle in c.kwargs.get("text", "")
|
||||
if c.kwargs.get("text", "") == text
|
||||
]
|
||||
|
||||
|
||||
class TestSectionStreak:
|
||||
"""Tests for StatusWindow._section_streak (display only)."""
|
||||
"""Tests for StatusWindow._section_streak (display only, glyphs + numbers)."""
|
||||
|
||||
def test_shows_the_real_week_count_in_success_color(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
"""The streak line shows the real week count in the success colour."""
|
||||
"""One bar per streak week plus the real count, in the success colour."""
|
||||
window = _make_window(mock_tk, _snapshot(streak=3))
|
||||
assert any("3-week streak" in t for t in _texts(mock_tk))
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, "3-week streak")
|
||||
line = f"{g.STREAK * 3} 3"
|
||||
assert line in _texts(mock_tk)
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, line)
|
||||
|
||||
def test_long_streak_caps_the_bars_not_the_number(self, mock_tk: MagicMock) -> None:
|
||||
"""Past 10 weeks the bars stop growing; the number stays exact."""
|
||||
_make_window(mock_tk, _snapshot(streak=14))
|
||||
assert f"{g.STREAK * 10} 14" in _texts(mock_tk)
|
||||
|
||||
def test_zero_streak_is_stated_plainly_not_padded(self, mock_tk: MagicMock) -> None:
|
||||
"""A zero streak reads No streak in the muted colour, with no week count."""
|
||||
"""A zero streak is one bar and 0, muted, never padded."""
|
||||
window = _make_window(mock_tk, _snapshot(streak=0))
|
||||
texts = _texts(mock_tk)
|
||||
assert any("No streak" in t for t in texts)
|
||||
assert not any("-week streak" in t for t in texts)
|
||||
assert window._colors.palette.muted in _fg_of(mock_tk, "No streak")
|
||||
line = f"{g.STREAK} 0"
|
||||
assert line in _texts(mock_tk)
|
||||
assert window._colors.palette.muted in _fg_of(mock_tk, line)
|
||||
|
||||
def test_early_bird_is_never_rendered(self, mock_tk: MagicMock) -> None:
|
||||
"""The 09:00 early-bird extension is gone (wake-alarm owns it)."""
|
||||
@ -49,19 +56,24 @@ class TestSectionStreak:
|
||||
def test_week_reward_reaches_the_window_in_success_color(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
"""A week reward message is shown in the success colour."""
|
||||
"""A week reward shows as bar + gift, success colour; never its words."""
|
||||
msg = "5 workouts in 2026-W41! 4-week streak"
|
||||
window = _make_window(mock_tk, _snapshot(streak=4, week_rewards=(msg,)))
|
||||
assert msg in _texts(mock_tk)
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, msg)
|
||||
assert msg not in _texts(mock_tk)
|
||||
line = f"{g.STREAK}{g.GIFT}"
|
||||
assert window._colors.palette.success in _fg_of(mock_tk, line)
|
||||
|
||||
def test_no_reward_text_when_the_week_has_none(self, mock_tk: MagicMock) -> None:
|
||||
"""No reward line appears when the week has no reward."""
|
||||
def test_no_reward_line_when_the_week_has_none(self, mock_tk: MagicMock) -> None:
|
||||
"""No reward or reset line appears when the week has no reward."""
|
||||
_make_window(mock_tk, _snapshot(streak=4))
|
||||
assert not any("workouts in 20" in t for t in _texts(mock_tk))
|
||||
texts = _texts(mock_tk)
|
||||
assert f"{g.STREAK}{g.GIFT}" not in texts
|
||||
assert f"{g.STREAK}{g.FAIL}" not in texts
|
||||
|
||||
def test_a_streak_reset_is_not_painted_as_a_win(self, mock_tk: MagicMock) -> None:
|
||||
"""A streak-reset message is shown in the warning colour, not as a win."""
|
||||
"""A streak reset shows as bar + fail in the warning colour, not as a win."""
|
||||
msg = "Streak reset (was 3 weeks of 5+ workouts)"
|
||||
window = _make_window(mock_tk, _snapshot(streak=0, week_rewards=(msg,)))
|
||||
assert window._colors.palette.warning in _fg_of(mock_tk, msg)
|
||||
assert _fg_of(mock_tk, f"{g.STREAK}{g.FAIL}") == [
|
||||
window._colors.palette.warning
|
||||
]
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render)."""
|
||||
"""Tests for the read-only Tkinter status window's rendering (StatusWindow.render).
|
||||
|
||||
textgate: every section is glyphs (``screen_locker._glyphs``) and numbers,
|
||||
so these assert glyphs, numbers and colours, never English words.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import LockConfig
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._compliance_state import AutoUpgradeOpportunity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -18,12 +25,22 @@ from screen_locker.tests._status_view_helpers import (
|
||||
_week,
|
||||
)
|
||||
|
||||
_PALETTE = LockConfig().palette
|
||||
|
||||
|
||||
def _colored(mock_tk: MagicMock) -> list[tuple[str, str]]:
|
||||
"""(text, fg) of every Label, in call order."""
|
||||
return [
|
||||
(c.kwargs.get("text", ""), c.kwargs.get("fg", ""))
|
||||
for c in mock_tk.Label.call_args_list
|
||||
]
|
||||
|
||||
|
||||
class TestSectionToday:
|
||||
"""SectionToday."""
|
||||
|
||||
def test_counted_shows_checkmark_and_entry(self, mock_tk: MagicMock) -> None:
|
||||
"""Counted shows checkmark and entry."""
|
||||
"""A counted day shows the tick and its entry's glyph, not the source."""
|
||||
snap = _snapshot(
|
||||
today=_day(
|
||||
entry_types=("phone_verified",),
|
||||
@ -34,64 +51,53 @@ class TestSectionToday:
|
||||
)
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert any("✓" in t and "phone_verified" in t for t in texts)
|
||||
assert any(t == "gym" for t in texts)
|
||||
assert f"{g.OK} {g.LIFT}" in texts
|
||||
assert "gym" not in texts
|
||||
|
||||
def test_sick_day_shows_sick_mark(self, mock_tk: MagicMock) -> None:
|
||||
"""Sick day shows sick mark."""
|
||||
"""Sick day shows the sick cross twice: mark and cell."""
|
||||
snap = _snapshot(today=_day(entry_types=(), is_sick_day=True))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("😷" in t and "sick day" in t for t in _texts(mock_tk))
|
||||
assert f"{g.SICK} {g.WEEK_SICK}" in _texts(mock_tk)
|
||||
|
||||
def test_no_entry_shows_dash(self, mock_tk: MagicMock) -> None:
|
||||
"""No entry shows dash."""
|
||||
def test_no_entry_shows_fail_and_empty_circle(self, mock_tk: MagicMock) -> None:
|
||||
"""No entry: the fail mark over an empty circle, in warning colour."""
|
||||
snap = _snapshot(today=_day(entry_types=(), is_sick_day=False))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("no entry yet" in t for t in _texts(mock_tk))
|
||||
assert (f"{g.FAIL} {g.WEEK_EMPTY}", _PALETTE.warning) in _colored(mock_tk)
|
||||
|
||||
def test_empty_source_adds_no_extra_line(self, mock_tk: MagicMock) -> None:
|
||||
"""Empty source adds no extra line."""
|
||||
snap = _snapshot(
|
||||
today=_day(
|
||||
entry_types=("phone_verified",),
|
||||
counted=True,
|
||||
day_count=1,
|
||||
source="",
|
||||
)
|
||||
)
|
||||
def test_unknown_entry_type_falls_back_to_tick(self, mock_tk: MagicMock) -> None:
|
||||
"""An entry type with no glyph shows as a tick, never as its name."""
|
||||
snap = _snapshot(today=_day(entry_types=("mystery",), counted=True))
|
||||
_make_window(mock_tk, snap)
|
||||
# Only the main "Today (...)" line, no second call for an empty source.
|
||||
today_related = [t for t in _texts(mock_tk) if "Today" in t]
|
||||
assert len(today_related) == 1
|
||||
texts = _texts(mock_tk)
|
||||
assert f"{g.OK} {g.OK}" in texts
|
||||
assert not any("mystery" in t for t in texts)
|
||||
|
||||
|
||||
class TestSectionWeek:
|
||||
"""SectionWeek."""
|
||||
|
||||
def test_shows_remaining_message_when_under_minimum(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
"""Shows remaining message when under minimum."""
|
||||
def test_under_minimum_is_warning_dots(self, mock_tk: MagicMock) -> None:
|
||||
"""Under the minimum: filled/empty dots and the count, warning colour."""
|
||||
snap = _snapshot(week=_week(counted_count=2, remaining=2, extra=0))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("Need 2 more this week." in t for t in _texts(mock_tk))
|
||||
assert (g.dots(2, 4), _PALETTE.warning) in _colored(mock_tk)
|
||||
|
||||
def test_shows_extra_message_when_over_minimum(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows extra message when over minimum."""
|
||||
def test_over_minimum_is_success_dots(self, mock_tk: MagicMock) -> None:
|
||||
"""Over the minimum: the count above the total, success colour."""
|
||||
snap = _snapshot(week=_week(counted_count=5, remaining=0, extra=1))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("above the weekly minimum" in t for t in _texts(mock_tk))
|
||||
assert ("●●●● 5/4", _PALETTE.success) in _colored(mock_tk)
|
||||
|
||||
def test_shows_neither_message_at_exact_minimum(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows neither message at exact minimum."""
|
||||
def test_exact_minimum_is_success(self, mock_tk: MagicMock) -> None:
|
||||
"""At exactly the minimum the week is met: success colour."""
|
||||
snap = _snapshot(week=_week(counted_count=4, remaining=0, extra=0))
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert not any("Need" in t and "more this week" in t for t in texts)
|
||||
assert not any("above the weekly minimum" in t for t in texts)
|
||||
assert (g.dots(4, 4), _PALETTE.success) in _colored(mock_tk)
|
||||
|
||||
def test_renders_a_line_per_day(self, mock_tk: MagicMock) -> None:
|
||||
"""Renders a line per day."""
|
||||
def test_renders_one_cell_per_day(self, mock_tk: MagicMock) -> None:
|
||||
"""One row, one cell per day in order, no weekday names."""
|
||||
days = (
|
||||
_day(
|
||||
date="2024-01-01",
|
||||
@ -106,9 +112,8 @@ class TestSectionWeek:
|
||||
snap = _snapshot(week=_week(days=days))
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert any("Mon Jan 01" in t and "✓" in t for t in texts)
|
||||
assert any("Tue Jan 02" in t and "😷" in t for t in texts)
|
||||
assert any("Wed Jan 03" in t and "no entry" in t for t in texts)
|
||||
assert f"{g.LIFT} {g.WEEK_SICK} {g.WEEK_EMPTY}" in texts
|
||||
assert not any("Mon" in t for t in texts)
|
||||
|
||||
|
||||
class TestSectionLockExplanation:
|
||||
@ -117,10 +122,11 @@ class TestSectionLockExplanation:
|
||||
def test_fired_with_auto_upgrade_and_heat_skip_pending(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
"""Fired with auto upgrade and heat skip pending."""
|
||||
"""Fired: the lock square + stage glyph in danger, and a retry mark."""
|
||||
snap = _snapshot(
|
||||
lock_explanation=_lock_explanation(
|
||||
fired=True,
|
||||
stage="would_lock",
|
||||
reason="Full lock.",
|
||||
auto_upgrade=AutoUpgradeOpportunity(
|
||||
would_attempt=True, via="sick_day", reason="will try phone"
|
||||
@ -129,19 +135,26 @@ class TestSectionLockExplanation:
|
||||
)
|
||||
)
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert any(t == "Full lock." for t in texts)
|
||||
assert any("Pending auto-upgrade: will try phone" in t for t in texts)
|
||||
colored = _colored(mock_tk)
|
||||
assert (f"{g.LOCK} {g.LIFT}", _PALETTE.danger) in colored
|
||||
assert (g.RETRY, _PALETTE.warning) in colored
|
||||
assert "Full lock." not in _texts(mock_tk)
|
||||
|
||||
def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None:
|
||||
"""Not fired no auto upgrade."""
|
||||
"""Not fired: the open square + stage glyph in success, no retry mark."""
|
||||
snap = _snapshot(
|
||||
lock_explanation=_lock_explanation(fired=False, reason="Skipped.")
|
||||
)
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert any(t == "Skipped." for t in texts)
|
||||
assert not any("Pending auto-upgrade" in t for t in texts)
|
||||
colored = _colored(mock_tk)
|
||||
assert (f"{g.UNLOCK} {g.OK}", _PALETTE.success) in colored
|
||||
assert (g.RETRY, _PALETTE.warning) not in colored
|
||||
|
||||
def test_unknown_stage_shows_only_the_square(self, mock_tk: MagicMock) -> None:
|
||||
"""A stage without a glyph shows just the square, no trailing space."""
|
||||
snap = _snapshot(lock_explanation=_lock_explanation(stage="brand_new_step"))
|
||||
_make_window(mock_tk, snap)
|
||||
assert g.UNLOCK in _texts(mock_tk)
|
||||
|
||||
def test_temperature_section_renders_regardless_of_heat_skip_evaluated(
|
||||
self, mock_tk: MagicMock
|
||||
@ -152,28 +165,28 @@ class TestSectionLockExplanation:
|
||||
lock_explanation=_lock_explanation(fired=True, heat_skip_evaluated=True)
|
||||
)
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("Warsaw Temperature" in t for t in _texts(mock_tk))
|
||||
assert any(t.startswith(g.HEAT) for t in _texts(mock_tk))
|
||||
|
||||
|
||||
class TestSectionShutdown:
|
||||
"""SectionShutdown."""
|
||||
|
||||
def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None:
|
||||
"""Tonight present shows live config."""
|
||||
"""Tonight present shows both live times after the shutdown glyph."""
|
||||
snap = _snapshot(shutdown=_shutdown(tonight=(1320, 1380, 300)))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk))
|
||||
assert f"{g.SHUTDOWN} 22:00 · 23:00" in _texts(mock_tk)
|
||||
|
||||
def test_tonight_absent_shows_unavailable_message(self, mock_tk: MagicMock) -> None:
|
||||
"""Tonight absent shows unavailable message."""
|
||||
def test_tonight_absent_shows_fail_glyph(self, mock_tk: MagicMock) -> None:
|
||||
"""Tonight absent shows the shutdown glyph crossed out, warning colour."""
|
||||
snap = _snapshot(shutdown=_shutdown(tonight=None))
|
||||
_make_window(mock_tk, snap)
|
||||
assert any("Live shutdown config unavailable." in t for t in _texts(mock_tk))
|
||||
assert (f"{g.SHUTDOWN} {g.FAIL}", _PALETTE.warning) in _colored(mock_tk)
|
||||
|
||||
def test_shows_rest_of_week_and_next_week_preview(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows rest of week and next week preview."""
|
||||
"""Rest of week and next week are rows of times, muted, no labels."""
|
||||
snap = _snapshot()
|
||||
_make_window(mock_tk, snap)
|
||||
texts = _texts(mock_tk)
|
||||
assert any("Rest of week:" in t for t in texts)
|
||||
assert any("Next week (speculative):" in t for t in texts)
|
||||
row = " ".join(["21:00"] * 7)
|
||||
assert _colored(mock_tk).count((row, _PALETTE.muted)) == 2
|
||||
assert not any("two separate systems" in t for t in _texts(mock_tk))
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._status_view_verify import PhoneCheckOutcome
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.status_view import (
|
||||
@ -20,6 +21,8 @@ from screen_locker.tests._status_view_helpers import (
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPhoneCheckResultRendering:
|
||||
"""PhoneCheckResultRendering."""
|
||||
@ -28,17 +31,17 @@ class TestPhoneCheckResultRendering:
|
||||
"""No result shows nothing."""
|
||||
snap = _snapshot()
|
||||
_make_window(mock_tk, snap)
|
||||
assert not any("Phone check" in t for t in _texts(mock_tk))
|
||||
assert not any(f"{g.RUN}{g.FAIL}" in t for t in _texts(mock_tk))
|
||||
|
||||
def test_verified_result_shown_in_green(self, mock_tk: MagicMock) -> None:
|
||||
"""Verified result shown in green."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._phone_check_result = ("verified", "Workout verified!")
|
||||
window._phone_check_result = ("verified", "verified glyphs")
|
||||
window.render(_snapshot())
|
||||
calls = [
|
||||
c
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if "Phone check" in c.kwargs.get("text", "")
|
||||
if c.kwargs.get("text") == "verified glyphs"
|
||||
]
|
||||
assert calls
|
||||
assert calls[-1].kwargs.get("fg") == window._colors.palette.success
|
||||
@ -46,12 +49,11 @@ class TestPhoneCheckResultRendering:
|
||||
def test_non_verified_result_shown_in_orange(self, mock_tk: MagicMock) -> None:
|
||||
"""Non verified result shown in orange."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._phone_check_result = ("no_phone", "Phone not connected")
|
||||
failed = f"{g.LIFT}{g.FAIL} {g.RUN}{g.FAIL}"
|
||||
window._phone_check_result = ("no_phone", failed)
|
||||
window.render(_snapshot())
|
||||
calls = [
|
||||
c
|
||||
for c in mock_tk.Label.call_args_list
|
||||
if "Phone check" in c.kwargs.get("text", "")
|
||||
c for c in mock_tk.Label.call_args_list if c.kwargs.get("text") == failed
|
||||
]
|
||||
assert calls
|
||||
assert calls[-1].kwargs.get("fg") == window._colors.palette.warning
|
||||
@ -65,12 +67,7 @@ class TestButtons:
|
||||
) -> None:
|
||||
"""Renders check phone refresh close."""
|
||||
_make_window(mock_tk, _snapshot(), log_file=temp_log_file)
|
||||
assert _button_texts(mock_tk) == {
|
||||
"Check Phone",
|
||||
"Log Manual Workout",
|
||||
"Refresh",
|
||||
"Close",
|
||||
}
|
||||
assert _button_texts(mock_tk) == {g.PHONE, g.FORM, g.RETRY, g.CLOSE}
|
||||
|
||||
|
||||
class TestRefreshAndCheckPhoneFlow:
|
||||
@ -124,9 +121,9 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
mock_handle.assert_not_called()
|
||||
|
||||
def test_failed_check_shows_combined_message_and_rerenders(
|
||||
self, mock_tk: MagicMock
|
||||
self, mock_tk: MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Neither source verified → show both messages, apply no credit."""
|
||||
"""Neither source verified → both fail glyphs, both reasons logged."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.gather_status", return_value=_snapshot()
|
||||
@ -136,8 +133,10 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
)
|
||||
assert window._phone_check_result == (
|
||||
"no_phone",
|
||||
"StrongLifts: no lifts · RunnerUp: no run",
|
||||
f"{g.LIFT}{g.FAIL} {g.RUN}{g.FAIL}",
|
||||
)
|
||||
warned = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert any("no lifts" in m and "no run" in m for m in warned)
|
||||
assert window._credit_message is None
|
||||
mock_gather.assert_called_once()
|
||||
|
||||
@ -174,8 +173,7 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
assert window._phone_check_result is None
|
||||
message = window._credit_message
|
||||
assert message is not None
|
||||
assert "StrongLifts verified: StrongLifts ok" in message
|
||||
assert "Shutdown time +2h later!" in message
|
||||
assert message.splitlines() == [f"{g.LIFT} {g.OK}", f"{g.SHUTDOWN} +2h"]
|
||||
|
||||
def test_runnerup_verified_credits_using_runnerup_message(
|
||||
self, mock_tk: MagicMock, temp_log_file: Path
|
||||
@ -210,7 +208,7 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
}
|
||||
message = window._credit_message
|
||||
assert message is not None
|
||||
assert "RunnerUp verified: RunnerUp 5.0 km" in message
|
||||
assert message == f"{g.RUN} {g.OK}"
|
||||
|
||||
|
||||
class TestMakeBareVerifier:
|
||||
|
||||
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._status_view_verify import (
|
||||
_backfill_week_and_apply_bonus,
|
||||
)
|
||||
@ -46,7 +47,7 @@ class TestBackfillWeekAndApplyBonus:
|
||||
):
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
assert message == "Auto-filled 1 workout from earlier this week."
|
||||
assert message == f"{g.RETRY}{g.LIFT} +1"
|
||||
verifier._scan_and_fill_week_runnerup.assert_called_once_with(
|
||||
verifier.log_file, on_ingested=verifier._credit_ingested_workout
|
||||
)
|
||||
@ -65,9 +66,7 @@ class TestBackfillWeekAndApplyBonus:
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
# prev 4 + 1 RunnerUp fill already credited = 5; 7 - 5 = 2 surplus.
|
||||
assert message == (
|
||||
"Auto-filled 2 workouts from earlier this week. +2h shutdown time."
|
||||
)
|
||||
assert message == f"{g.RETRY}{g.LIFT} +2 {g.SHUTDOWN} +2h"
|
||||
verifier._adjust_shutdown_time_by.assert_called_once_with(120)
|
||||
|
||||
def test_stronglifts_fill_below_minimum_earns_no_surplus(self) -> None:
|
||||
@ -82,5 +81,5 @@ class TestBackfillWeekAndApplyBonus:
|
||||
):
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
assert message == "Auto-filled 1 workout from earlier this week."
|
||||
assert message == f"{g.RETRY}{g.LIFT} +1"
|
||||
verifier._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._status_view_verify import (
|
||||
PhoneCheckOutcome,
|
||||
_verify_phone_then_runnerup,
|
||||
@ -68,8 +69,6 @@ class TestVerifyPhoneThenRunnerup:
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
|
||||
assert result.credited_type is None
|
||||
assert (
|
||||
result.week_fill_message == "Auto-filled 1 workout from earlier this week."
|
||||
)
|
||||
assert result.week_fill_message == f"{g.RETRY}{g.LIFT} +1"
|
||||
# The RunnerUp fill is credited by the shared callback, not here.
|
||||
verifier._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker.status_view import (
|
||||
StatusWindow,
|
||||
main,
|
||||
@ -93,21 +94,21 @@ class TestTemperatureSectionRendering:
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = None
|
||||
window.render(_snapshot())
|
||||
assert any("Checking Warsaw temperature" in t for t in _texts(mock_tk))
|
||||
assert f"{g.HEAT} {g.WAIT}" in _texts(mock_tk)
|
||||
|
||||
def test_shows_resolved_temperature(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows resolved temperature."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = _temp_check(temp_celsius=22.0)
|
||||
window.render(_snapshot())
|
||||
assert any("Warsaw: 22" in t for t in _texts(mock_tk))
|
||||
assert f"{g.HEAT} 22°C < 33°C" in _texts(mock_tk)
|
||||
|
||||
def test_flags_would_trigger_heat_skip_when_hot(self, mock_tk: MagicMock) -> None:
|
||||
"""Flags would trigger heat skip when hot."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = _temp_check(temp_celsius=35.0)
|
||||
window.render(_snapshot())
|
||||
assert any("Would trigger heat-skip today." in t for t in _texts(mock_tk))
|
||||
assert f"{g.HEAT} 35°C ≥ 33°C {g.SKIP}" in _texts(mock_tk)
|
||||
|
||||
def test_does_not_flag_heat_skip_when_below_threshold(
|
||||
self, mock_tk: MagicMock
|
||||
@ -116,21 +117,28 @@ class TestTemperatureSectionRendering:
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = _temp_check(temp_celsius=22.0)
|
||||
window.render(_snapshot())
|
||||
assert not any("Would trigger heat-skip today." in t for t in _texts(mock_tk))
|
||||
assert not any(g.SKIP in t for t in _texts(mock_tk) if t.startswith(g.HEAT))
|
||||
|
||||
def test_shows_timeout_message(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows timeout message."""
|
||||
def test_shows_timeout_message(
|
||||
self, mock_tk: MagicMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A timeout shows the clock-fail glyph and logs the timeout."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = _temp_check(temp_celsius=None, timed_out=True)
|
||||
window.render(_snapshot())
|
||||
assert any("timed out" in t for t in _texts(mock_tk))
|
||||
assert f"{g.HEAT} {g.CLOCK}{g.FAIL}" in _texts(mock_tk)
|
||||
assert any(
|
||||
"timed out" in r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelname == "WARNING"
|
||||
)
|
||||
|
||||
def test_shows_failure_message_when_not_timed_out(self, mock_tk: MagicMock) -> None:
|
||||
"""Shows failure message when not timed out."""
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
window._temp_result = _temp_check(temp_celsius=None, timed_out=False)
|
||||
window.render(_snapshot())
|
||||
assert any("failed (network/API error)" in t for t in _texts(mock_tk))
|
||||
assert f"{g.HEAT} {g.FAIL}" in _texts(mock_tk)
|
||||
|
||||
|
||||
class TestTemperatureCheckFlow:
|
||||
|
||||
146
screen_locker/tests/test_ui_flows_glyphs.py
Normal file
146
screen_locker/tests/test_ui_flows_glyphs.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""The lock screens' glyphs (textgate): what each state paints, and what it logs.
|
||||
|
||||
Every sentence these screens used to paint now goes to the log at WARNING,
|
||||
so each test checks both halves: the glyph on screen, the reason in the log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker import _ui_flows
|
||||
from screen_locker._source_health import SourceFinding
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _labels(mock_tk: MagicMock) -> list[tuple[str, str]]:
|
||||
"""(text, fg) of every Label, in call order."""
|
||||
return [
|
||||
(c.kwargs.get("text", ""), c.kwargs.get("fg", ""))
|
||||
for c in mock_tk.Label.call_args_list
|
||||
]
|
||||
|
||||
|
||||
def _warned(caplog: pytest.LogCaptureFixture) -> str:
|
||||
"""Every WARNING message of the test, joined."""
|
||||
return "\n".join(r.getMessage() for r in caplog.records if r.levelname == "WARNING")
|
||||
|
||||
|
||||
class TestRetryScreen:
|
||||
"""``_show_retry_and_sick``: barbell-fail, status glyph, sync faults."""
|
||||
|
||||
def test_too_short_paints_its_glyph_and_logs_the_reason(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A too-short session shows the short-clock glyph; its reason is logged."""
|
||||
monkeypatch.setattr(_ui_flows, "collect_source_findings", list)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._handle_startup_phone_result("too_short", "only 12 min")
|
||||
labels = _labels(mock_tk)
|
||||
palette = locker._colors.palette
|
||||
assert (f"{g.LIFT} {g.FAIL}", palette.danger) in labels
|
||||
assert (g.SHORT, palette.warning) in labels
|
||||
assert "only 12 min" in _warned(caplog)
|
||||
assert not any("only 12 min" in text for text, _fg in labels)
|
||||
|
||||
def test_no_status_paints_no_status_glyph(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Called without a status (the penalty timer's end), no status glyph."""
|
||||
monkeypatch.setattr(_ui_flows, "collect_source_findings", list)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._show_retry_and_sick("phone gone")
|
||||
texts = [text for text, _fg in _labels(mock_tk)]
|
||||
assert not any(text in g.STATUS_GLYPH.values() for text in texts)
|
||||
assert g.FAIL not in texts
|
||||
|
||||
def test_each_sync_finding_is_one_glyph_and_logged(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Two unreadable sources: two sync-fail glyphs; the prose goes to the log."""
|
||||
findings = [
|
||||
SourceFinding("firebase", "firebase unreadable", "401"),
|
||||
SourceFinding("mirror", "mirror stale", "9 days"),
|
||||
]
|
||||
monkeypatch.setattr(_ui_flows, "collect_source_findings", lambda: findings)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._show_retry_and_sick("x", status="stale")
|
||||
assert (
|
||||
f"{g.SYNC}{g.FAIL} " * 2,
|
||||
locker._colors.palette.warning,
|
||||
) in _labels(mock_tk)
|
||||
assert "firebase unreadable" in _warned(caplog)
|
||||
assert "mirror stale" in _warned(caplog)
|
||||
|
||||
|
||||
class TestAcceptedScreen:
|
||||
"""``_paint_accepted``: source glyph + tick, wait dots, then unlock."""
|
||||
|
||||
def test_verified_lift_paints_tick_and_schedules_unlock(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A verified lift paints barbell + tick in success, then the wait dots."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker.root, "after", MagicMock())
|
||||
locker._handle_startup_phone_result("verified", "Workout A")
|
||||
labels = _labels(mock_tk)
|
||||
assert (f"{g.LIFT} {g.OK}", locker._colors.palette.success) in labels
|
||||
assert g.WAIT in [text for text, _fg in labels]
|
||||
assert not any("Workout A" in text for text, _fg in labels)
|
||||
assert locker.workout_data["source"] == "Workout A"
|
||||
delay, callback = locker.root.after.call_args.args
|
||||
assert delay > 0
|
||||
assert callback == locker.unlock_screen
|
||||
|
||||
def test_manual_save_paints_the_form_glyph(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A saved manual entry paints form + tick, never the entry's source."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(locker.root, "after", MagicMock())
|
||||
with patch.object(locker, "unlock_screen"):
|
||||
locker._on_manual_workout_saved({"source": "table tennis at Solec"})
|
||||
texts = [text for text, _fg in _labels(mock_tk)]
|
||||
assert f"{g.FORM} {g.OK}" in texts
|
||||
assert not any("Solec" in t for t in texts)
|
||||
|
||||
|
||||
class TestGlyphHelpers:
|
||||
"""The pure helpers in ``_glyphs``."""
|
||||
|
||||
def test_dots_fills_up_to_total_and_keeps_the_real_count(self) -> None:
|
||||
"""Dots never exceed the total; the numbers stay exact."""
|
||||
assert g.dots(2, 4) == "●●○○ 2/4"
|
||||
assert g.dots(6, 4) == "●●●● 6/4"
|
||||
assert g.dots(-1, 3) == "○○○ -1/3"
|
||||
|
||||
def test_status_glyph_falls_back_to_fail(self) -> None:
|
||||
"""A status with no glyph shows the plain fail mark."""
|
||||
assert g.status_glyph("too_short") == g.SHORT
|
||||
assert g.status_glyph("brand_new_status") == g.FAIL
|
||||
|
||||
def test_entry_glyphs_maps_known_and_unknown_types(self) -> None:
|
||||
"""Known types get their glyph; an unknown one shows a tick, in order."""
|
||||
assert g.entry_glyphs(("runnerup_verified", "mystery")) == f"{g.RUN} {g.OK}"
|
||||
assert g.entry_glyphs(()) == ""
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker import _glyphs as g
|
||||
from screen_locker._compliance_predicates import is_relaxed_day_skipped_today
|
||||
from screen_locker.tests.conftest import (
|
||||
create_locker,
|
||||
@ -69,9 +70,7 @@ class TestStartRelaxedDayFlow:
|
||||
locker._start_relaxed_day_flow()
|
||||
|
||||
skip_cmds = [
|
||||
c.kwargs["command"]
|
||||
for c in mock_button.call_args_list
|
||||
if "Skip" in str(c.args)
|
||||
c.kwargs["command"] for c in mock_button.call_args_list if g.SKIP in c.args
|
||||
]
|
||||
assert locker._skip_relaxed_day in skip_cmds
|
||||
|
||||
@ -120,7 +119,7 @@ class TestStartRelaxedDayFlow:
|
||||
log_cmds = [
|
||||
c.kwargs["command"]
|
||||
for c in mock_button.call_args_list
|
||||
if "Log" in str(c.args)
|
||||
if f"{g.GO} {g.LIFT}" in c.args
|
||||
]
|
||||
assert locker._start_relaxed_phone_check in log_cmds
|
||||
|
||||
@ -233,5 +232,5 @@ class TestHandleRelaxedPhoneResult:
|
||||
button_texts = {
|
||||
call.kwargs.get("text") for call in mock_tk.Button.call_args_list
|
||||
}
|
||||
assert "TRY AGAIN" in button_texts
|
||||
assert "Close (Skip)" in button_texts
|
||||
assert g.RETRY in button_texts
|
||||
assert g.SKIP in button_texts
|
||||
|
||||
@ -9,12 +9,10 @@ check). Each build paints into ``surface.content`` like the fit check's.
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker._temperature import TemperatureCheck
|
||||
from screen_locker._ui_flows import UIFlowsMixin
|
||||
from screen_locker._unlock_view import UnlockViewMixin
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.status_view import StatusWindow
|
||||
@ -22,6 +20,7 @@ from scripts.verify_screen_fits import ScreenHost
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class _Host(UnlockViewMixin, ScreenHost):
|
||||
@ -46,7 +45,7 @@ class _Host(UnlockViewMixin, ScreenHost):
|
||||
|
||||
def paint_manual_saved(self) -> None:
|
||||
"""The real confirmation screen (ScreenHost stubs the hook out)."""
|
||||
UIFlowsMixin._on_manual_workout_saved(self, {"source": "x"})
|
||||
super(ScreenHost, self)._on_manual_workout_saved({"source": "x"})
|
||||
|
||||
|
||||
def _host_paint(method: str, *args: object) -> Callable[[object], None]:
|
||||
|
||||
@ -31,7 +31,7 @@ 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._manual_workout_date import REJECTED_GLYPH
|
||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||
from screen_locker._surface_group import FrameGroup
|
||||
from screen_locker._ui_flows import UIFlowsMixin
|
||||
@ -147,12 +147,7 @@ class ScreenHost(
|
||||
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."
|
||||
)
|
||||
)
|
||||
self._mw_error_label.config(text=REJECTED_GLYPH)
|
||||
|
||||
def paint_sick_refused(self) -> None:
|
||||
"""Paint the screen shown when today cannot be marked sick."""
|
||||
|
||||
17
scripts/verify_text_free.py
Normal file → Executable file
17
scripts/verify_text_free.py
Normal file → Executable file
@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tkinter as tk
|
||||
@ -34,6 +35,11 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
INNER_ENV = "_TEXT_FREE_CHECK_INNER"
|
||||
_SIZE = (1366, 768)
|
||||
# Screens whose words come from a package this repo does not own: reported,
|
||||
# not failed, so the gate stays meaningful for screen-locker's own text.
|
||||
EXTERNAL: dict[str, str] = {
|
||||
"sick-keep-panel": "sickdays.gui.KeepPanel (kuhyx/utils, sickdays)",
|
||||
}
|
||||
_TYPED = frozenset({"Entry", "Spinbox", "Text", "TEntry", "TSpinbox", "TCombobox"})
|
||||
|
||||
|
||||
@ -69,8 +75,12 @@ def _widget_texts(widget: tk.Misc) -> list[str]:
|
||||
def _shoot(win: tk.Toplevel, path: Path) -> None:
|
||||
"""Save *win* as a PNG; a failed shot is logged, never fatal."""
|
||||
win.update()
|
||||
tool = shutil.which("import")
|
||||
if tool is None:
|
||||
_logger.warning("no ImageMagick import on PATH: %s not saved", path)
|
||||
return
|
||||
result = subprocess.run(
|
||||
["import", "-window", str(win.winfo_id()), str(path)],
|
||||
[tool, "-window", str(win.winfo_id()), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
@ -118,6 +128,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
root.withdraw()
|
||||
bad = _paint_all(root, shots)
|
||||
root.destroy()
|
||||
for name, owner in EXTERNAL.items():
|
||||
if name in bad:
|
||||
_logger.warning(
|
||||
"external %s (%s): %s", name, owner, " ".join(bad.pop(name))
|
||||
)
|
||||
if not bad:
|
||||
_logger.info("OK: no locker screen paints a word.")
|
||||
return 0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user