diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 874cd6f..bfa0f75 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/screen_locker/_credit_notify.py b/screen_locker/_credit_notify.py index 8feaa5a..508ff58 100644 --- a/screen_locker/_credit_notify.py +++ b/screen_locker/_credit_notify.py @@ -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: diff --git a/screen_locker/_glyphs.py b/screen_locker/_glyphs.py new file mode 100644 index 0000000..2c52fad --- /dev/null +++ b/screen_locker/_glyphs.py @@ -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) diff --git a/screen_locker/_heat_skip.py b/screen_locker/_heat_skip.py index 9e83982..cae672e 100644 --- a/screen_locker/_heat_skip.py +++ b/screen_locker/_heat_skip.py @@ -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, diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index ba630c9..00dd36e 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -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", diff --git a/screen_locker/_manual_workout_date.py b/screen_locker/_manual_workout_date.py index 266ef89..c4e7a20 100644 --- a/screen_locker/_manual_workout_date.py +++ b/screen_locker/_manual_workout_date.py @@ -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, diff --git a/screen_locker/_manual_workout_dialog.py b/screen_locker/_manual_workout_dialog.py index 6d6ec4e..713eaab 100644 --- a/screen_locker/_manual_workout_dialog.py +++ b/screen_locker/_manual_workout_dialog.py @@ -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 diff --git a/screen_locker/_manual_workout_sport_fields.py b/screen_locker/_manual_workout_sport_fields.py index 6c8140f..e1e8e0f 100644 --- a/screen_locker/_manual_workout_sport_fields.py +++ b/screen_locker/_manual_workout_sport_fields.py @@ -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.""" diff --git a/screen_locker/_manual_workout_types.py b/screen_locker/_manual_workout_types.py index 885ddde..d6483ac 100644 --- a/screen_locker/_manual_workout_types.py +++ b/screen_locker/_manual_workout_types.py @@ -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}" ) diff --git a/screen_locker/_manual_workout_widgets.py b/screen_locker/_manual_workout_widgets.py index e719400..93620ed 100644 --- a/screen_locker/_manual_workout_widgets.py +++ b/screen_locker/_manual_workout_widgets.py @@ -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, diff --git a/screen_locker/_status_sections.py b/screen_locker/_status_sections.py index 64006cc..a053403 100644 --- a/screen_locker/_status_sections.py +++ b/screen_locker/_status_sections.py @@ -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, + ) diff --git a/screen_locker/_status_view_verify.py b/screen_locker/_status_view_verify.py index 144133e..ce6bc4a 100644 --- a/screen_locker/_status_view_verify.py +++ b/screen_locker/_status_view_verify.py @@ -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 diff --git a/screen_locker/_temperature_status_mixin.py b/screen_locker/_temperature_status_mixin.py index ec6c20e..04e9a34 100644 --- a/screen_locker/_temperature_status_mixin.py +++ b/screen_locker/_temperature_status_mixin.py @@ -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. diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 8ab1a0c..d3c315c 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -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: diff --git a/screen_locker/_ui_flows_lockout.py b/screen_locker/_ui_flows_lockout.py index 82879f9..8af88d7 100644 --- a/screen_locker/_ui_flows_lockout.py +++ b/screen_locker/_ui_flows_lockout.py @@ -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), diff --git a/screen_locker/_ui_flows_relaxed.py b/screen_locker/_ui_flows_relaxed.py index 7eed9b6..f51ee5d 100644 --- a/screen_locker/_ui_flows_relaxed.py +++ b/screen_locker/_ui_flows_relaxed.py @@ -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")) diff --git a/screen_locker/_ui_flows_sick.py b/screen_locker/_ui_flows_sick.py index d989a9a..a303f6e 100644 --- a/screen_locker/_ui_flows_sick.py +++ b/screen_locker/_ui_flows_sick.py @@ -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")) diff --git a/screen_locker/_ui_flows_sick_panel.py b/screen_locker/_ui_flows_sick_panel.py index 15a1cbc..c3f6b7e 100644 --- a/screen_locker/_ui_flows_sick_panel.py +++ b/screen_locker/_ui_flows_sick_panel.py @@ -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() diff --git a/screen_locker/_unlock_view.py b/screen_locker/_unlock_view.py index 78b49e2..3542a6b 100644 --- a/screen_locker/_unlock_view.py +++ b/screen_locker/_unlock_view.py @@ -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) diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index 600ed56..c7fd435 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -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()) diff --git a/screen_locker/tests/test_credit_notify.py b/screen_locker/tests/test_credit_notify.py index 7f0026a..a171e7c 100644 --- a/screen_locker/tests/test_credit_notify.py +++ b/screen_locker/tests/test_credit_notify.py @@ -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" ) diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py index c8c65c9..93d9dad 100644 --- a/screen_locker/tests/test_manual_workout.py +++ b/screen_locker/tests/test_manual_workout.py @@ -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( diff --git a/screen_locker/tests/test_manual_workout_date.py b/screen_locker/tests/test_manual_workout_date.py index f857ffe..8e56c26 100644 --- a/screen_locker/tests/test_manual_workout_date.py +++ b/screen_locker/tests/test_manual_workout_date.py @@ -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: diff --git a/screen_locker/tests/test_manual_workout_features.py b/screen_locker/tests/test_manual_workout_features.py index e403a3f..74f8ae4 100644 --- a/screen_locker/tests/test_manual_workout_features.py +++ b/screen_locker/tests/test_manual_workout_features.py @@ -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: diff --git a/screen_locker/tests/test_manual_workout_status_view.py b/screen_locker/tests/test_manual_workout_status_view.py index 8eb4951..d989cfc 100644 --- a/screen_locker/tests/test_manual_workout_status_view.py +++ b/screen_locker/tests/test_manual_workout_status_view.py @@ -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: diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 967c844..821d732 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -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, diff --git a/screen_locker/tests/test_screen_lock_coverage_part4.py b/screen_locker/tests/test_screen_lock_coverage_part4.py index d010623..6241833 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part4.py +++ b/screen_locker/tests/test_screen_lock_coverage_part4.py @@ -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, diff --git a/screen_locker/tests/test_sick_button.py b/screen_locker/tests/test_sick_button.py index 8438468..e1fd058 100644 --- a/screen_locker/tests/test_sick_button.py +++ b/screen_locker/tests/test_sick_button.py @@ -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] diff --git a/screen_locker/tests/test_sick_panel.py b/screen_locker/tests/test_sick_panel.py index 654f118..a50ea0b 100644 --- a/screen_locker/tests/test_sick_panel.py +++ b/screen_locker/tests/test_sick_panel.py @@ -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] diff --git a/screen_locker/tests/test_sick_panel_part2.py b/screen_locker/tests/test_sick_panel_part2.py index e812edc..1f16295 100644 --- a/screen_locker/tests/test_sick_panel_part2.py +++ b/screen_locker/tests/test_sick_panel_part2.py @@ -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 diff --git a/screen_locker/tests/test_status_streak_section.py b/screen_locker/tests/test_status_streak_section.py index 7d2725e..d5eadf6 100644 --- a/screen_locker/tests/test_status_streak_section.py +++ b/screen_locker/tests/test_status_streak_section.py @@ -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 + ] diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 7d46176..0168cdd 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -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)) diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py index 8e2a8c7..c64a377 100644 --- a/screen_locker/tests/test_status_view_part2.py +++ b/screen_locker/tests/test_status_view_part2.py @@ -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: diff --git a/screen_locker/tests/test_status_view_part4.py b/screen_locker/tests/test_status_view_part4.py index 78a3a7e..f492eae 100644 --- a/screen_locker/tests/test_status_view_part4.py +++ b/screen_locker/tests/test_status_view_part4.py @@ -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() diff --git a/screen_locker/tests/test_status_view_part5.py b/screen_locker/tests/test_status_view_part5.py index 5776317..fe546c9 100644 --- a/screen_locker/tests/test_status_view_part5.py +++ b/screen_locker/tests/test_status_view_part5.py @@ -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() diff --git a/screen_locker/tests/test_status_view_part6.py b/screen_locker/tests/test_status_view_part6.py index 789efcc..3198477 100644 --- a/screen_locker/tests/test_status_view_part6.py +++ b/screen_locker/tests/test_status_view_part6.py @@ -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: diff --git a/screen_locker/tests/test_ui_flows_glyphs.py b/screen_locker/tests/test_ui_flows_glyphs.py new file mode 100644 index 0000000..81bfdfc --- /dev/null +++ b/screen_locker/tests/test_ui_flows_glyphs.py @@ -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(()) == "" diff --git a/screen_locker/tests/test_weekly_logic_part4.py b/screen_locker/tests/test_weekly_logic_part4.py index b6e41e6..a4ac6a7 100644 --- a/screen_locker/tests/test_weekly_logic_part4.py +++ b/screen_locker/tests/test_weekly_logic_part4.py @@ -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 diff --git a/scripts/_text_free_screens.py b/scripts/_text_free_screens.py index 32fdba5..25acebd 100644 --- a/scripts/_text_free_screens.py +++ b/scripts/_text_free_screens.py @@ -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]: diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py index 3efa4ce..561f2f7 100755 --- a/scripts/verify_screen_fits.py +++ b/scripts/verify_screen_fits.py @@ -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.""" diff --git a/scripts/verify_text_free.py b/scripts/verify_text_free.py old mode 100644 new mode 100755 index 488c67b..4bf93dc --- a/scripts/verify_text_free.py +++ b/scripts/verify_text_free.py @@ -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