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:
Krzysztof Rudnicki 2026-11-11 01:20:23 +01:00
parent 3073faa623
commit e5bfdd9a9a
41 changed files with 912 additions and 575 deletions

View File

@ -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

View File

@ -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
View 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)

View File

@ -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,

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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."""

View File

@ -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}"
)

View File

@ -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,

View File

@ -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,
)

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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),

View File

@ -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"))

View File

@ -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"))

View File

@ -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()

View File

@ -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)

View File

@ -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())

View File

@ -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"
)

View File

@ -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(

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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,

View File

@ -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,

View File

@ -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]

View File

@ -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]

View File

@ -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

View File

@ -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
]

View File

@ -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))

View File

@ -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:

View File

@ -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()

View File

@ -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()

View File

@ -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:

View 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(()) == ""

View File

@ -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

View File

@ -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]:

View File

@ -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
View 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