diff --git a/app/pubspec.yaml b/app/pubspec.yaml index aaff0bb..4d194a3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.146+1 +version: 1.0.147+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_calendar_view.py b/diet_guard/_calendar_view.py index 70f80a8..7a8a8b0 100644 --- a/diet_guard/_calendar_view.py +++ b/diet_guard/_calendar_view.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING from gatelock import LockConfig -from diet_guard._averages import PeriodAverage, band_label +from diet_guard import _gatelock_glyphs as g from diet_guard._averages_report import monthly_average, weekly_average from diet_guard._daystatus import ( DayStatus, @@ -28,6 +28,7 @@ from diet_guard._gatelock_ui import BG, FG from diet_guard._state import now_local if TYPE_CHECKING: + from diet_guard._averages import PeriodAverage from diet_guard._budget_history import BudgetSchedule from diet_guard._state import DayLog @@ -128,11 +129,6 @@ def cell_style(status: DayStatus | None) -> tuple[str, str, str]: return color, _STATUS_TEXT_COLOR, color -def _plural(count: int) -> str: - """Return "day" or "days" for ``count``.""" - return "day" if count == 1 else "days" - - def streaks_text( status_map_: dict[str, DayStatus], *, @@ -145,10 +141,8 @@ def streaks_text( """ logging = logging_streak(status_map_, today=today) adherence = adherence_streak(status_map_, today=today) - return ( - f"Logging streak: {logging} {_plural(logging)} · " - f"Adherence streak: {adherence} {_plural(adherence)}" - ) + # Logged-days streak under the pencil, within-budget streak under the check. + return f"{g.FOOD} {logging} {g.OK} {adherence}" def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str: @@ -159,16 +153,16 @@ def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> """ tally = year_to_date_tally(status_map_, today=today) return ( - f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · " - f"{tally.adherent_days} within budget" + f"{g.YEAR_TOTAL} {g.FOOD} {tally.logged_days}/{tally.elapsed_days} " + f"{g.OK} {tally.adherent_days}" ) def _average_part(period: PeriodAverage) -> str: - """Render one period as ``"NNNN kcal (band)"``, or ``"no data"``.""" - if period.avg_kcal is None: - return band_label(period.band) - return f"{period.avg_kcal:.0f} kcal ({band_label(period.band)})" + """Render one period as ``"NNNN kcal "``, or a dash.""" + if period.avg_kcal is None or period.band is None: + return g.NO_VALUE + return f"{period.avg_kcal:.0f} kcal {g.BAND[period.band.value]}" def averages_text( @@ -190,6 +184,6 @@ def averages_text( week = weekly_average(log, schedule=schedule, today=today) month = monthly_average(log, schedule=schedule, today=today) return ( - f"Avg/day (to yesterday): week {_average_part(week)} \u00b7 " - f"month {_average_part(month)}" + f"{g.AVERAGE} {g.SPAN['week']} {_average_part(week)} " + f"{g.AVERAGE} {g.SPAN['month']} {_average_part(month)}" ) diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 40ae8df..1bbff0f 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -131,7 +131,7 @@ class MealGate(_GateCalendar): self._state = _GateState() self.root = GateRoot() self.root.on_callback_error = self.on_callback_error - self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) + self.root.title("Diet Gate") # OS-level app name: the one kept noun # No bg=/fg=/... overrides here: LockConfig's own defaults already # are the unified-design-system palette, and _gatelock_ui reads the # same LockConfig() rather than a separate hardcoded copy -- see diff --git a/diet_guard/_gatelock_body.py b/diet_guard/_gatelock_body.py index 08a1de6..07bfa1f 100644 --- a/diet_guard/_gatelock_body.py +++ b/diet_guard/_gatelock_body.py @@ -18,18 +18,9 @@ from typing import TYPE_CHECKING from gatelock import ScrollableSurface +from diet_guard import _gatelock_glyphs as g from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat from diet_guard._body_prefs import saved_goal, set_goal -from diet_guard._body_report import ( - activity_lines, - bmi_lines, - bmr_lines, - bodyfat_lines, - ideal_lines, - plan_lines, - profile_lines, - target_lines, -) from diet_guard._body_store import ( MAX_KG, MIN_KG, @@ -39,6 +30,16 @@ from diet_guard._body_store import ( ) from diet_guard._body_view import BodyView, budget_or_default, build_view from diet_guard._daystatus import day_total_kcal +from diet_guard._gatelock_body_glyphs import ( + activity_glyphs, + bmi_glyphs, + bmr_glyphs, + bodyfat_glyphs, + ideal_glyphs, + plan_glyphs, + profile_glyphs, + target_glyphs, +) from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range from diet_guard._gatelock_body_input import ( HEIGHT_CM, @@ -63,7 +64,9 @@ if TYPE_CHECKING: __all__ = ["BodyTab"] -_DAY_HINT = "Type the YYYY-MM-DD day to delete." +# The day field's expected shape, shown when it does not parse. +_DAY_SHAPE = "0000-00-00" +_DAY_HINT = f"{g.ERROR} {g.DELETE} {_DAY_SHAPE}" class BodyTab: @@ -119,17 +122,16 @@ class BodyTab: view = build_view(today, self._goal) target = None if view.numbers is None else view.numbers.target kcal = None if target is None else target.kcal - self.vars.goal.answer.set("" if kcal is None else f"eat {kcal} kcal") - lines = target_lines(view) + self.vars.goal.answer.set("" if kcal is None else f"→ {kcal} kcal") texts = { - "summary": profile_lines(view, today), - "table": lines[1:] if kcal is not None else lines, - "bodyfat": bodyfat_lines(view), - "bmi": bmi_lines(view), - "ideal": ideal_lines(view), - "bmr": bmr_lines(view), - "workouts": activity_lines(view), - "plan": plan_lines(self._plan(today)), + "summary": profile_glyphs(view, today), + "table": target_glyphs(view), + "bodyfat": bodyfat_glyphs(view), + "bmi": bmi_glyphs(view), + "ideal": ideal_glyphs(view), + "bmr": bmr_glyphs(view), + "workouts": activity_glyphs(view), + "plan": plan_glyphs(self._plan(today)), } for name, lines in texts.items(): self.vars.blocks[name].set("\n".join(lines)) @@ -166,8 +168,9 @@ class BodyTab: def _saved(self, message: str) -> None: self.vars.status.set(message) + # Saved here, but the publish failed: the reason goes to the log only. publish_after_log_detached( - lambda why: self.vars.status.set(f"Saved; not yet synced ({why}).") + lambda _why: self.vars.status.set(f"{g.OK} {g.ERROR} {g.SYNC}") ) self.refresh() @@ -176,7 +179,7 @@ class BodyTab: self._goal = goal_choice(self.vars.goal, self._goal) load_goal(self.vars.goal, self._goal) set_goal(self._goal) - self._saved("Goal saved.") + self._saved(g.OK) def save_profile(self) -> None: """Validate and store birth date, height and sex.""" @@ -189,20 +192,22 @@ class BodyTab: or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1] or sex not in {"m", "f"} ): - self.vars.status.set("Profile needs a YYYY-MM-DD birth date, cm and sex.") + self.vars.status.set(f"{g.ERROR} {g.BIRTH} cm {g.MALE}{g.FEMALE}") return set_profile(birth=birth, height_cm=height, sex=sex) - self._saved("Profile saved -- it moves to the bottom next time.") + self._saved(g.OK) def save_weight(self) -> None: """Validate and store the typed weigh-in.""" kg = parse_float(self.vars.logs.weight.get()) day = parse_day(self.vars.logs.weight_day.get()) if kg is None or day is None or not MIN_KG <= kg <= MAX_KG: - self.vars.status.set(f"Weight needs {MIN_KG:g}-{MAX_KG:g} kg and a day.") + self.vars.status.set( + f"{g.ERROR} {MIN_KG:g}{g.DASH}{MAX_KG:g} kg {_DAY_SHAPE}" + ) return self._store_weight(day, kg) - self._saved(f"Saved {kg:g} kg for {day}.") + self._saved(f"{g.OK} {kg:g} kg {day}") def delete_weight(self) -> None: """Delete the weigh-in for the typed day.""" @@ -211,7 +216,7 @@ class BodyTab: self.vars.status.set(_DAY_HINT) return self._store_weight(day, None) - self._saved(f"Deleted the weigh-in for {day}.") + self._saved(f"{g.DELETE} {day}") @staticmethod def _store_weight(day: str, kg: float | None) -> None: @@ -225,11 +230,11 @@ class BodyTab: pct = parse_float(self.vars.logs.body_fat.get()) day = parse_day(self.vars.logs.fat_day.get()) if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT: - limits = f"{MIN_BODY_FAT:g}-{MAX_BODY_FAT:g}%" - self.vars.status.set(f"Body fat needs {limits} and a day.") + limits = f"{MIN_BODY_FAT:g}{g.DASH}{MAX_BODY_FAT:g}%" + self.vars.status.set(f"{g.ERROR} {limits} {_DAY_SHAPE}") return set_body_fat(day, pct) - self._saved(f"Saved {pct:g}% body fat for {day}.") + self._saved(f"{g.OK} {pct:g}% {day}") def delete_body_fat(self) -> None: """Delete the body-fat reading for the typed day.""" @@ -238,4 +243,4 @@ class BodyTab: self.vars.status.set(_DAY_HINT) return set_body_fat(day, None) - self._saved(f"Deleted the body-fat reading for {day}.") + self._saved(f"{g.DELETE} {day}") diff --git a/diet_guard/_gatelock_body_glyphs.py b/diet_guard/_gatelock_body_glyphs.py new file mode 100644 index 0000000..ccc7cc3 --- /dev/null +++ b/diet_guard/_gatelock_body_glyphs.py @@ -0,0 +1,185 @@ +"""The Body tab's report blocks as glyphs and numbers -- the gate's half. + +:mod:`diet_guard._body_report` renders the same :class:`BodyView` as English +sentences for the terminal (``diet_guard body``), and terminal output keeps +its text. The gate is a window kuhy sees, so it gets this text-free twin: +one glyph per concept from :mod:`._gatelock_glyphs`, numbers through the +same ``half_up`` so both surfaces round identically. Method names that only +explained a number (formula names, BMI Prime, Trefethen, the per-formula +ideal weights) are dropped here; the CLI still prints them. +""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING + +from diet_guard import _gatelock_glyphs as g +from diet_guard._activity_kcal import FORMULAS, session_kcal +from diet_guard._body_calc import half_up +from diet_guard._body_energy import MEASURED_PREFIX, MIN_TARGET_KCAL +from diet_guard._body_store import age_on + +if TYPE_CHECKING: + from diet_guard._body_view import BodyView + from diet_guard._week_plan import WeekPlan + +__all__ = [ + "ACTIVITY", + "activity_glyphs", + "bmi_glyphs", + "bmr_glyphs", + "bodyfat_glyphs", + "ideal_glyphs", + "plan_glyphs", + "profile_glyphs", + "target_glyphs", +] + +_SESSIONS_SHOWN = 10 +# Measured-exercise methods: MET keeps its acronym (a DOUBT row in keep.md), +# ACSM's speed equations are a slope, Per-km is its own unit. +METHOD: dict[str, str] = {"MET": "MET", "ACSM": "↗", "Per-km": "/km"} +# The activity picker: a rising bar per level, then the measured methods. +ACTIVITY: dict[str, str] = { + "sedentary": "▁", + "light": "▂", + "moderate": "▄", + "very": "▆", + "extra": "█", + **{f"{MEASURED_PREFIX}{n}": f"{g.MEASURED} {METHOD[n]}" for n in FORMULAS}, +} +# Week-plan day sources: logged, typed, still planned, missed. +_SOURCE = {"logged": g.OK, "typed": g.EDIT, "plan": "○", "unlogged": "?"} + + +def _num(value: float, digits: int = 0) -> str: + """``value`` rounded half-up to ``digits`` decimals.""" + return f"{half_up(value, digits):.{digits}f}" + + +def _band(category: str) -> str: + """The WHO band as a shape: thin, normal, over, obese; dash under 20.""" + for word, glyph in ( + ("thinness", "▽"), + ("normal", g.OK), + ("overweight", "△"), + ("obese", "▲"), + ): + if word in category: + return glyph + return g.NO_VALUE + + +def profile_glyphs(view: BodyView, today: date) -> list[str]: + """Birth date and age, height, sex; then the newest weight.""" + prof = view.profile + birth = g.NO_VALUE + if prof.birth is not None: + age = age_on(date.fromisoformat(prof.birth), today) + birth = f"{prof.birth} ({age})" + height = f"{_num(prof.height_cm)} cm" if prof.height_cm else f"{g.NO_VALUE} cm" + sex = {"m": g.MALE, "f": g.FEMALE}.get(prof.sex or "", g.NO_VALUE) + newest = max(view.weights) if view.weights else None + weight = ( + f"{_num(view.weights[newest], 1)} kg {newest} {g.TIMES}{len(view.weights)}" + if newest + else f"{g.NO_VALUE} kg" + ) + return [f"{g.BIRTH} {birth} {height} {sex}", f"{g.AMOUNT} {weight}"] + + +def bodyfat_glyphs(view: BodyView) -> list[str]: + """The newest body-fat reading, or a dash.""" + if not view.body_fats: + return [f"% {g.NO_VALUE}"] + day = max(view.body_fats) + return [f"{_num(view.body_fats[day], 1)}% {day} {g.TIMES}{len(view.body_fats)}"] + + +def bmi_glyphs(view: BodyView) -> list[str]: + """BMI with its band shape, and the healthy weight range.""" + if view.numbers is None: + return [f"{g.TAB_BODY} ?"] + bmi = view.numbers.bmi + low, high = view.numbers.healthy + return [ + f"{g.TAB_BODY} {_num(bmi.bmi, 1)} {_band(bmi.category)}", + f"{g.OK} {_num(low, 1)}{g.DASH}{_num(high, 1)} kg", + ] + + +def ideal_glyphs(view: BodyView) -> list[str]: + """The ideal-weight average and range over every formula.""" + summary = None if view.numbers is None else view.numbers.ideal_summary + if summary is None: + return [f"{g.IDEAL} ?"] + avg, low, high = summary + return [f"{g.IDEAL} {_num(avg, 1)} kg ({_num(low, 1)}{g.DASH}{_num(high, 1)})"] + + +def bmr_glyphs(view: BodyView) -> list[str]: + """Resting burn: one number per formula that has its inputs.""" + if view.numbers is None: + return [f"{g.RESTING} ?"] + values = [_num(f.value) for f in view.numbers.bmr if f.value is not None] + return [f"{g.RESTING} {' '.join(values)} kcal"] + + +def target_glyphs(view: BodyView) -> list[str]: + """Maintenance burn, and a warning when the target is under the floor.""" + target = None if view.numbers is None else view.numbers.target + if target is None or target.kcal is None or target.tdee is None: + return [f"{g.MEASURED} {g.EMPTY}"] if target is not None else ["?"] + lines = [f"= {_num(target.tdee)} kcal"] + if target.below_floor: + lines.append(f"{g.WARN} < {MIN_TARGET_KCAL} kcal") + return lines + + +def activity_glyphs(view: BodyView) -> list[str]: + """Measured means per method, steps, and the newest published workouts.""" + if view.activity_stamp is None and not view.steps: + return [f"{g.MEASURED} {g.EMPTY}"] + stamp = (view.activity_stamp or "")[:16].replace("T", " ") or g.NO_VALUE + lines = [f"{g.SYNC} {stamp}"] + exercise = view.exercise + if exercise is not None: + means = " ".join( + f"{METHOD.get(name, name)} {_num(kcal)}" + for name, kcal in exercise.means.items() + ) + lines.append(f"{g.AVERAGE} {exercise.days} {means} kcal") + if exercise is not None and view.steps: + newest = max(view.steps) + extra = _num(exercise.steps_kcal) + lines.append(f"{g.STEPS} {view.steps[newest]} {newest} +{extra} kcal") + weight = view.weight + for session in view.sessions[-_SESSIONS_SHOWN:][::-1]: + km = f" {_num(session.km, 1)} km" if session.km else "" + kcal = "" + if weight is not None: + result = session_kcal(session, weight) + kcal = " " + " ".join( + f"{METHOD[name]} {_num(result.kcal[name])}" for name in FORMULAS + ) + # session.kind is the workout's own name, published by the PC: content. + lines.append( + f" {session.day} {session.kind:<8}{_num(session.minutes):>4} min{km}{kcal}" + ) + return lines + + +def plan_glyphs(plan: WeekPlan) -> list[str]: + """The week plan: day of month, kcal, and where the number came from.""" + lines = [f"{g.AVERAGE} {plan.target_avg} kcal"] + lines.extend( + f" {day.day[-2:]} {day.kcal:>5} {_SOURCE.get(day.source, '?')}" + for day in plan.days + ) + if plan.per_remaining is None: + lines.append(f"{g.OK} {g.AVERAGE} {_num(plan.week_avg)} kcal") + else: + warn = f" {g.WARN}" if plan.below_floor else "" + lines.append(f"→ {plan.per_remaining} kcal{warn}") + return lines diff --git a/diet_guard/_gatelock_body_graph.py b/diet_guard/_gatelock_body_graph.py index 5cca1c4..b75b7ee 100644 --- a/diet_guard/_gatelock_body_graph.py +++ b/diet_guard/_gatelock_body_graph.py @@ -18,6 +18,7 @@ from datetime import date, timedelta from typing import TYPE_CHECKING from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED +from diet_guard._gatelock_glyphs import EMPTY, INFINITE, OK from diet_guard._gatelock_typography import CAPTION from diet_guard._gatelock_ui import BG, FG @@ -34,15 +35,17 @@ __all__ = [ # (label, days back; None = everything). GRAPH_RANGES: tuple[tuple[str, int | None], ...] = ( - ("30 days", 30), - ("90 days", 90), - ("1 year", 365), - ("All", None), + ("30", 30), + ("90", 90), + ("365", 365), + (INFINITE, None), ) _HEALTHY_FILL = "#1f3a2a" _GRID = "#33363d" _DOT_R = 3 _TICKS = 4 +# Size of the empty-range mark, in points. +_EMPTY_SIZE = 48 # Keep a flat line off the frame: pad the value range by this much (kg). _MIN_SPAN_KG = 2.0 # Include a healthy-band edge on the axis only when it is this close (kg). @@ -140,8 +143,9 @@ def _draw_band( box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline="" ) font = (_COLORS.typography.font_family, CAPTION) + # The green band is the healthy-BMI weight range; a check marks it. canvas.create_text( - box.x(1) - 4, top + 2, text="healthy BMI", anchor="ne", fill=_MUTED, font=font + box.x(1) - 4, top + 2, text=OK, anchor="ne", fill=_MUTED, font=font ) @@ -151,16 +155,16 @@ def draw_weight_graph( box: GraphBox, healthy: tuple[float, float] | None, ) -> None: - """Redraw the whole graph; an empty log shows a hint instead.""" + """Redraw the whole graph; an empty range shows a large empty-set mark.""" canvas.delete("all") font = (_COLORS.typography.font_family, CAPTION) if not weights: canvas.create_text( box.width / 2, box.height / 2, - text="Nothing logged in this range yet.", + text=EMPTY, fill=_MUTED, - font=font, + font=(_COLORS.typography.font_family, _EMPTY_SIZE), ) return span = value_span(list(weights.values()), healthy) diff --git a/diet_guard/_gatelock_body_input.py b/diet_guard/_gatelock_body_input.py index 0ade485..86794a9 100644 --- a/diet_guard/_gatelock_body_input.py +++ b/diet_guard/_gatelock_body_input.py @@ -9,8 +9,9 @@ from __future__ import annotations from datetime import date from typing import TYPE_CHECKING -from diet_guard._body_energy import ACTIVITY_CHOICES, activity_label from diet_guard._body_prefs import goal_from_json +from diet_guard._gatelock_body_glyphs import ACTIVITY +from diet_guard._gatelock_glyphs import DIRECTION, SPAN if TYPE_CHECKING: from diet_guard._body_energy import Goal @@ -38,25 +39,29 @@ def parse_day(text: str) -> str | None: return None +def _back(glyphs: dict[str, str], shown: str, fallback: str) -> str: + """The stored key whose glyph a picker shows, or ``fallback``.""" + return next((key for key, glyph in glyphs.items() if glyph == shown), fallback) + + def load_goal(goal_vars: GoalVars, goal: Goal) -> None: - """Put ``goal`` into the sentence's pickers.""" - goal_vars.direction.set(goal.direction) + """Put ``goal`` into the goal row's pickers, as glyphs (units stay).""" + goal_vars.direction.set(DIRECTION.get(goal.direction, goal.direction)) goal_vars.amount.set(f"{goal.amount:g}") goal_vars.weight_unit.set(goal.weight_unit) - goal_vars.time_unit.set(goal.time_unit) - goal_vars.activity.set(activity_label(goal.activity)) + goal_vars.time_unit.set(SPAN.get(goal.time_unit, goal.time_unit)) + goal_vars.activity.set(ACTIVITY.get(goal.activity, goal.activity)) def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal: """The goal the pickers show; an unreadable amount keeps ``fallback``'s.""" - labels = {activity_label(key): key for key in ACTIVITY_CHOICES} amount = parse_float(goal_vars.amount.get()) return goal_from_json( { - "dir": goal_vars.direction.get(), + "dir": _back(DIRECTION, goal_vars.direction.get(), fallback.direction), "amt": fallback.amount if amount is None or amount < 0 else amount, "wu": goal_vars.weight_unit.get(), - "tu": goal_vars.time_unit.get(), - "act": labels.get(goal_vars.activity.get(), fallback.activity), + "tu": _back(SPAN, goal_vars.time_unit.get(), fallback.time_unit), + "act": _back(ACTIVITY, goal_vars.activity.get(), fallback.activity), } ) diff --git a/diet_guard/_gatelock_body_widgets.py b/diet_guard/_gatelock_body_widgets.py index 0c2fff5..1e41857 100644 --- a/diet_guard/_gatelock_body_widgets.py +++ b/diet_guard/_gatelock_body_widgets.py @@ -23,21 +23,21 @@ from typing import TYPE_CHECKING from gatelock import ButtonStyle, make_button +from diet_guard import _gatelock_glyphs as g from diet_guard._body_energy import ( ACTIVITY_CHOICES, DIRECTIONS, PRESET_AMOUNTS, TIME_UNITS, WEIGHT_UNITS, - activity_label, ) +from diet_guard._gatelock_body_glyphs import ACTIVITY from diet_guard._gatelock_body_graph import GRAPH_RANGES from diet_guard._gatelock_body_vars import BodyWidgets from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED from diet_guard._gatelock_spacing import MD, SM, XS from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE from diet_guard._gatelock_ui import BG, FG -from diet_guard._week_plan import WEEKDAYS if TYPE_CHECKING: from collections.abc import Callable @@ -103,37 +103,35 @@ def _menu( def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: - """``To [lose] [0.5][v] [kg] / [week] on [Moderate] eat 2019 kcal``.""" + """``[↓] [0.5][v] [kg] / [◑] [▄] → 2019 kcal``: every choice a glyph.""" goal = vars_.goal row = _row(parent) - _label(row, "To", SUBTITLE) - _menu(row, goal.direction, list(DIRECTIONS), cb) + _menu(row, goal.direction, [g.DIRECTION[d] for d in DIRECTIONS], cb) _entry(row, goal.amount, 5, SUBTITLE) _menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb) _menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb) _label(row, "/", SUBTITLE) - _menu(row, goal.time_unit, list(TIME_UNITS), cb) - _label(row, "on", SUBTITLE) - _menu(row, goal.activity, [activity_label(k) for k in ACTIVITY_CHOICES], cb) + _menu(row, goal.time_unit, [g.SPAN[t] for t in TIME_UNITS], cb) + _menu(row, goal.activity, [ACTIVITY[k] for k in ACTIVITY_CHOICES], cb) tk.Label( row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT ).pack(side="left", padx=(MD, 0)) - _button(row, "Apply typed amount", cb.goal_changed) + _button(row, g.OK, cb.goal_changed) _block(parent, vars_.blocks["table"]) def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: row = _row(parent) - _label(row, "Profile -- born (YYYY-MM-DD)") + _label(row, g.BIRTH) _entry(row, vars_.profile.birth, 11) - _label(row, "Height cm") + _label(row, "cm") _entry(row, vars_.profile.height, 6) sex = vars_.profile.sex - for value, text in (("m", "male"), ("f", "female")): + for value, text in (("m", g.MALE), ("f", g.FEMALE)): tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack( side="left" ) - _button(row, "Save profile", cb.save_profile) + _button(row, g.SAVE, cb.save_profile) _block(parent, vars_.blocks["summary"]) @@ -146,18 +144,18 @@ def _dated_row( row = _row(parent) _label(row, label) _entry(row, vars_pair[0], 6) - _label(row, "on") + _label(row, g.CLOCK) _entry(row, vars_pair[1], 11) - _button(row, "Save", actions[0]) - _button(row, "Delete that day", actions[1]) + _button(row, g.SAVE, actions[0]) + _button(row, g.DELETE, actions[1]) def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas: logs = vars_.logs pair = (logs.weight, logs.weight_day) - _dated_row(col, "Weight kg", pair, (cb.save_weight, cb.delete_weight)) + _dated_row(col, f"{g.AMOUNT} kg", pair, (cb.save_weight, cb.delete_weight)) row = _row(col) - for value, text in (("weight", "Weight"), ("fat", "Body fat")): + for value, text in (("weight", g.AMOUNT), ("fat", "%")): tk.Radiobutton( row, text=text, @@ -181,12 +179,13 @@ def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: for name in ("bmi", "ideal", "bmr"): _block(col, vars_.blocks[name]) row = _row(col) - _label(row, "Week average target kcal") + _label(row, f"{g.AVERAGE} kcal") _entry(row, vars_.plan.target, 6) - for name, var in zip(WEEKDAYS, vars_.plan.days, strict=True): - _label(row, name) + # Seven day boxes under dots, the weekend pair larger (no day names). + for dot, var in zip(g.WEEKDAY_DOTS, vars_.plan.days, strict=True): + _label(row, dot) _entry(row, var, 5) - _button(row, "Plan week", cb.recalc) + _button(row, g.OK, cb.recalc) _block(col, vars_.blocks["plan"]) @@ -195,9 +194,6 @@ def build_body_frame( ) -> BodyWidgets: """Build the whole tab into ``parent``; return what the controller needs.""" frame = tk.Frame(parent, bg=BG) - tk.Label(frame, text="Body", font=(*_font(TITLE), "bold"), bg=BG, fg=FG).pack( - pady=(MD, SM) - ) inner = tk.Frame(frame, bg=BG) inner.pack(fill="both", expand=True, padx=MD) if profile_first: @@ -215,6 +211,6 @@ def build_body_frame( _build_profile(inner, vars_, cb) logs = vars_.logs pair = (logs.body_fat, logs.fat_day) - _dated_row(inner, "Body fat %", pair, (cb.save_body_fat, cb.delete_body_fat)) + _dated_row(inner, "%", pair, (cb.save_body_fat, cb.delete_body_fat)) _block(inner, vars_.blocks["bodyfat"]) return BodyWidgets(frame=frame, canvas=canvas) diff --git a/diet_guard/_gatelock_budgetedit.py b/diet_guard/_gatelock_budgetedit.py index 2e28c86..3d98226 100644 --- a/diet_guard/_gatelock_budgetedit.py +++ b/diet_guard/_gatelock_budgetedit.py @@ -18,6 +18,7 @@ import contextlib import tkinter as tk from typing import TYPE_CHECKING +from diet_guard import _gatelock_glyphs as g from diet_guard._budget import BudgetError, write_budget from diet_guard._budget_derived import budget_weight from diet_guard._gatelock_mealflow import _GateMealFlow @@ -79,13 +80,13 @@ class _GateBudgetEdit(_GateMealFlow): if not self._cal_editing_budget: self._cal_editing_budget = True self._set_budget_entry_state("normal") - self._set_budget_button_text("Save") + self._set_budget_button_text(g.SAVE) self._set_budget_status("", error=False) return if not self._save_budget_entry(): return self._cal_editing_budget = False - self._set_budget_button_text("Edit") + self._set_budget_button_text(g.EDIT) self._refresh_calendar() self._refresh_dashboard() self._refresh_projection() @@ -104,15 +105,15 @@ class _GateBudgetEdit(_GateMealFlow): try: value = int(raw) except ValueError: - self._set_budget_status("Enter a whole number of kcal.", error=True) + self._set_budget_status(f"{g.ERROR} kcal", error=True) return False if value <= 0: - self._set_budget_status("Budget must be a positive number.", error=True) + self._set_budget_status(f"{g.ERROR} kcal > 0", error=True) return False try: weight = budget_weight() except BudgetError: weight = None write_budget(value, weight_kg=weight) - self._set_budget_status("Saved.", error=False) + self._set_budget_status(g.OK, error=False) return True diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py index 5983d55..98d8e3d 100644 --- a/diet_guard/_gatelock_calendar.py +++ b/diet_guard/_gatelock_calendar.py @@ -16,12 +16,12 @@ are unit-tested directly without a fake Tk in the loop. from __future__ import annotations -import calendar import contextlib import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING +from diet_guard import _gatelock_glyphs as glyphs from diet_guard._budget import ( BudgetFileCorruptError, BudgetNotInitializedError, @@ -84,6 +84,7 @@ class _GateCalendar(_GateScheduleEdit): _cal_editing_budget: bool _cal_editing_schedule: bool _notebook: ttk.Notebook + _body_tab: BodyTab def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets: """Build one monitor's notebook, wire both tabs, return its meal widgets. @@ -109,7 +110,7 @@ class _GateCalendar(_GateScheduleEdit): widgets = build_layout( notebook, self._vars, callbacks, demo_mode=self.demo_mode ) - notebook.add(widgets.frame, text="Log Meal") + notebook.add(widgets.frame, text=glyphs.TAB_LOG) cal_callbacks = CalendarCallbacks( on_prev_month=self._on_prev_month, @@ -118,8 +119,9 @@ class _GateCalendar(_GateScheduleEdit): on_edit_or_save_schedule=self._on_edit_or_save_schedule, ) cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks) - notebook.add(cal_widgets.frame, text="History") - notebook.add(BodyTab(notebook).container, text="Body") + notebook.add(cal_widgets.frame, text=glyphs.TAB_HISTORY) + self._body_tab = BodyTab(notebook) + notebook.add(self._body_tab.container, text=glyphs.TAB_BODY) # Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching # all live in toplevel bindings that ttk only installs on request. The # notebook is in the focus ring and Left/Right work once it holds focus, @@ -159,7 +161,8 @@ class _GateCalendar(_GateScheduleEdit): schedule = current_schedule(default=budget) if budget is not None else None if schedule is None: self._render_month(None) - self._cal_vars.streaks.set("Budget file is corrupt -- fix it below.") + # Unreadable budget: warn at the budget flag, which is the fix. + self._cal_vars.streaks.set(f"{glyphs.WARN} {glyphs.BUDGET}") self._cal_vars.ytd.set("") self._cal_vars.averages.set("") else: @@ -176,9 +179,8 @@ class _GateCalendar(_GateScheduleEdit): def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None: """Redraw the day-cell grid and month label for the displayed month.""" weeks = build_month_cells(self._cal_year, self._cal_month, status_map_) - self._cal_vars.month_label.set( - f"{calendar.month_name[self._cal_month]} {self._cal_year}", - ) + # Numerals, not a month name: "11 · 2026". + self._cal_vars.month_label.set(f"{self._cal_month:02d} · {self._cal_year}") for surface in self._cal_surfaces: for index, cell_widget in enumerate(surface.day_cells): row, col = divmod(index, 7) diff --git a/diet_guard/_gatelock_calendar_schedule.py b/diet_guard/_gatelock_calendar_schedule.py index 03712db..a03aa0a 100644 --- a/diet_guard/_gatelock_calendar_schedule.py +++ b/diet_guard/_gatelock_calendar_schedule.py @@ -30,6 +30,7 @@ from typing import TYPE_CHECKING from gatelock import make_button from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG +from diet_guard._gatelock_glyphs import CLOCK, DASH, EDIT, TIMES from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_typography import BODY, LABEL from diet_guard._gatelock_ui import BG, FG @@ -168,13 +169,13 @@ def build_schedule_row( """ row = tk.Frame(parent, bg=BG) row.pack(pady=(SM, XS)) - _caption(row, "Meals:") + _caption(row, CLOCK) first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE) - _caption(row, "to") + _caption(row, DASH) last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE) - _caption(row, "x") + _caption(row, TIMES) count_entry = _spin_entry(row, vars_.schedule.count) - edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule) + edit_button = make_button(row, _COLORS, EDIT, on_edit_or_save_schedule) edit_button.pack(side="left") # The derived checkpoint times, so the effect of a change is visible diff --git a/diet_guard/_gatelock_calendar_types.py b/diet_guard/_gatelock_calendar_types.py index 511189b..fe6c466 100644 --- a/diet_guard/_gatelock_calendar_types.py +++ b/diet_guard/_gatelock_calendar_types.py @@ -17,6 +17,8 @@ from typing import TYPE_CHECKING from gatelock import LockConfig +from diet_guard._gatelock_glyphs import WEEKDAY, WEEKEND + if TYPE_CHECKING: from collections.abc import Callable from tkinter import ttk @@ -32,7 +34,7 @@ _ACCENT = _COLORS.palette.accent # calendar.monthcalendar never returns more than 6 weeks for any month. _MONTH_ROWS = 6 -_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su") +_WEEKDAY_LABELS = (WEEKDAY,) * 5 + (WEEKEND,) * 2 _JANUARY = 1 _DECEMBER = 12 _MONTH_AFTER_DECEMBER = 13 diff --git a/diet_guard/_gatelock_calendar_widgets.py b/diet_guard/_gatelock_calendar_widgets.py index 000952d..7737a1f 100644 --- a/diet_guard/_gatelock_calendar_widgets.py +++ b/diet_guard/_gatelock_calendar_widgets.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING from gatelock import ButtonStyle, make_button +from diet_guard import _gatelock_glyphs as glyphs from diet_guard._gatelock_calendar_schedule import build_schedule_row from diet_guard._gatelock_calendar_types import ( _ACCENT, @@ -33,7 +34,7 @@ from diet_guard._gatelock_calendar_types import ( CalendarWidgets, ) from diet_guard._gatelock_spacing import SM, XS -from diet_guard._gatelock_typography import BODY, CAPTION, LABEL, TITLE +from diet_guard._gatelock_typography import BODY, CAPTION, LABEL from diet_guard._gatelock_ui import BG, FG if TYPE_CHECKING: @@ -59,7 +60,7 @@ def _build_budget_row( row.pack(pady=(SM, XS)) tk.Label( row, - text="Daily budget (kcal):", + text=f"{glyphs.BUDGET} kcal", font=(_COLORS.typography.font_family, LABEL), bg=BG, fg=FG, @@ -80,7 +81,7 @@ def _build_budget_row( edit_button = make_button( row, _COLORS, - "Edit", + glyphs.EDIT, on_edit_or_save_budget, ) edit_button.pack(side="left") @@ -132,13 +133,15 @@ def _build_grid(parent: tk.Frame) -> list[tk.Label]: """Build the fixed 6x7 day-cell grid and return the flat cell list.""" weekday_row = tk.Frame(parent, bg=BG) weekday_row.pack() + # Seven dots, no day names: the weekend pair is larger and accented. for col, label in enumerate(_WEEKDAY_LABELS): + weekend = label == glyphs.WEEKEND tk.Label( weekday_row, text=label, font=(_COLORS.typography.font_family, CAPTION, "bold"), bg=BG, - fg=_MUTED, + fg=_ACCENT if weekend else _MUTED, width=4, ).grid(row=0, column=col, padx=XS) @@ -170,14 +173,6 @@ def build_calendar_frame( ) -> CalendarWidgets: """Lay out the History tab and return the widgets the controller drives.""" frame = tk.Frame(root, bg=BG) - tk.Label( - frame, - text="📅 History", - font=(_COLORS.typography.font_family, TITLE, "bold"), - bg=BG, - fg=_ACCENT, - ).pack(pady=(SM, 0)) - budget_entry, budget_edit_button, budget_status_label = _build_budget_row( frame, vars_, diff --git a/diet_guard/_gatelock_core.py b/diet_guard/_gatelock_core.py index fe1b7ab..9b8299c 100644 --- a/diet_guard/_gatelock_core.py +++ b/diet_guard/_gatelock_core.py @@ -15,6 +15,7 @@ import logging import tkinter as tk from typing import TYPE_CHECKING +from diet_guard._gatelock_glyphs import CLOCK, OK from diet_guard._gatelock_ui import ( BASIS_PREFIX_GRAMS, BASIS_PREFIX_ITEMS, @@ -108,11 +109,11 @@ class _GateCore: """Update the header to prompt for the slot now being collected.""" total = len(self._pending) if total == 0: - self._vars.slot_header.set("All meals logged.") + self._vars.slot_header.set(OK) return slot = self._pending[0] - position = "" if total == 1 else f" (1 of {total} remaining)" - self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}") + position = "" if total == 1 else f" 1/{total}" + self._vars.slot_header.set(f"{CLOCK} {slot_label(slot)}{position}") def _reset_per_default(self) -> None: """Set the "per" field to the basis default for the current unit.""" diff --git a/diet_guard/_gatelock_delivery.py b/diet_guard/_gatelock_delivery.py index f52ee7b..f5e73a7 100644 --- a/diet_guard/_gatelock_delivery.py +++ b/diet_guard/_gatelock_delivery.py @@ -21,6 +21,7 @@ import queue import sys from typing import TYPE_CHECKING +from diet_guard import _gatelock_glyphs as g from diet_guard._gate import due_slots from diet_guard._gatelock_fetch import FETCH_POLL_MS, start_fetch from diet_guard._gatelock_kuchnia import start_delivery_fetch @@ -43,7 +44,7 @@ if TYPE_CHECKING: # pragma: no cover - typing only _LAZY_ATTRS = ("refresh_delivery", "refresh_delivery_once") #: ``(unlock reason, nothing-new status)`` for a sync pull; "Fill all" has its own. -_SYNC_LABELS = ("Synced from another device", "No new meals found in sync.") +_SYNC_LABELS = (f"{g.OK} {g.SYNC}", f"{g.EMPTY} {g.SYNC}") def __getattr__(name: str) -> object: @@ -104,12 +105,12 @@ class _PullFlows(_GateNutrition): second click while a fetch is in flight is ignored. """ if self.demo_mode: - self._set_status("Loading a delivery is only available on the real lock.") + self._set_status(f"{g.DEMO_ONLY} {g.DELIVERY}") return if self._delivery_result is not None: return self._delivery_asked = True - self._set_status("Loading today's delivery…") + self._set_status(f"{g.BUSY} {g.DELIVERY}") # Through the module object so the lazy hook resolves it, and so a # test's ``patch.object`` still wins. self._delivery_result = start_delivery_fetch( @@ -131,11 +132,11 @@ class _PullFlows(_GateNutrition): self._delivery_result = None if outcome.reason is not None: if self._delivery_asked: - self._set_status(f"{outcome.reason} — still locked.", error=True) + self._set_status(f"{g.ERROR} {g.DELIVERY}", error=True) return if not outcome.dishes: if self._delivery_asked: - self._set_status("No catering delivery today.") + self._set_status(f"{g.EMPTY} {g.DELIVERY}") return self._delivery_pending = dishes_in_slot_order(outcome.dishes) self._prefill_next_dish() @@ -160,11 +161,9 @@ class _PullFlows(_GateNutrition): # dish at 3.5x its calories while the fields still read correctly. self._apply_reference(dish_nutrition(dish), name=dish.name) remaining = len(self._delivery_pending) - suffix = f" ({remaining} more to go)" if remaining else "" - prefix = f"{logged} — " if logged else "" - self._set_status( - f"{prefix}Loaded: {dish.name}{suffix} — check, then Log & Continue." - ) + suffix = f" +{remaining}" if remaining else "" + prefix = f"{logged} " if logged else "" + self._set_status(f"{prefix}{g.DELIVERY} {dish.name}{suffix}") def _autoload_delivery(self) -> None: """Start a guarded catering fetch as the lock opens. @@ -203,13 +202,13 @@ class _PullFlows(_GateNutrition): write-race fix in one. """ if self.demo_mode: - self._set_status("Fetch from sync is only available on the real lock.") + self._set_status(f"{g.DEMO_ONLY} {g.SYNC}") return if self._fetch_result is not None: # Already fetching. Without this a double-click starts a second # worker, orphans the first result and races two log writes. return - self._set_status("Fetching from sync…") + self._set_status(f"{g.BUSY} {g.SYNC}") self._fetch_result = start_fetch(pull_peer_logs) self.root.after(FETCH_POLL_MS, self._poll_fetch) @@ -225,7 +224,7 @@ class _PullFlows(_GateNutrition): return self._fetch_result = None if reason is not None: - self._set_status(f"{reason} — still locked.", error=True) + self._set_status(f"{g.ERROR} {g.SYNC}", error=True) return self._reconcile_after_fetch() @@ -244,7 +243,5 @@ class _PullFlows(_GateNutrition): return self._clear_inputs() self._refresh_slot_header() - count = len(satisfied_slots) - meal_word = "meal" if count == 1 else "meals" - self._set_status(f"Pulled {count} {meal_word} — next meal, please.") + self._set_status(f"{g.OK} {g.SYNC} {len(satisfied_slots)}") self._widgets.desc_text.focus_set() diff --git a/diet_guard/_gatelock_fields.py b/diet_guard/_gatelock_fields.py index 1586fd9..d3948ea 100644 --- a/diet_guard/_gatelock_fields.py +++ b/diet_guard/_gatelock_fields.py @@ -22,9 +22,10 @@ from __future__ import annotations import tkinter as tk from typing import TYPE_CHECKING +from diet_guard import _gatelock_glyphs as glyphs from diet_guard import _gatelock_ui from diet_guard._gatelock_spacing import SM, XS -from diet_guard._gatelock_typography import BODY, LABEL +from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE from diet_guard._gatelock_ui import ( _ACCENT, _COLORS, @@ -61,13 +62,7 @@ def build_desc(parent: tk.Frame) -> tk.Text: wraps onto a second line and stays fully visible, instead of scrolling off the right edge where the end can no longer be read. """ - tk.Label( - parent, - text="What did you eat?", - font=(_FONT, LABEL), - bg=BG, - fg=FG, - ).pack() + tk.Label(parent, text=glyphs.FOOD, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack() text = tk.Text( parent, font=(_FONT, BODY), @@ -121,13 +116,7 @@ def build_amount_row( on_unit_change: Callable[[str], None], ) -> tk.Entry: """Build the "how much did you eat?" amount + unit row; return the entry.""" - tk.Label( - parent, - text="How much did you eat?", - font=(_FONT, LABEL), - bg=BG, - fg=FG, - ).pack() + tk.Label(parent, text=glyphs.AMOUNT, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack() row = tk.Frame(parent, bg=BG) row.pack(pady=(XS, XS)) amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount) @@ -167,7 +156,7 @@ def _build_unit_selector( for unit in (UNIT_GRAMS, UNIT_ITEMS): tk.Radiobutton( row, - text=unit, + text=glyphs.ITEMS if unit == UNIT_ITEMS else glyphs.GRAMS, value=unit, variable=vars_.unit, command=lambda u=unit: on_unit_change(u), @@ -203,20 +192,21 @@ def build_macro_section( basis_prefix.pack(side="left") per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per) per_entry.pack(side="left", padx=XS, ipady=XS) - tk.Label( - basis, - text="g (leave calories blank to look it up):", - font=(_FONT, LABEL), - bg=BG, - fg=FG, - ).pack(side="left") + # Blank kcal still means "look it up"; the busy cue shows it happening. + tk.Label(basis, text="g", font=(_FONT, LABEL), bg=BG, fg=FG).pack(side="left") row = tk.Frame(parent, bg=BG) row.pack(pady=(XS, XS)) + # Shape + colour per macro, the phone's MacroKind vocabulary. + colors = glyphs.MACRO_COLORS macros = _MacroEntries( kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal), - protein=_macro_cell(root, row, "P", vars_.entries.protein), - carbs=_macro_cell(root, row, "C", vars_.entries.carbs), - fat=_macro_cell(root, row, "F", vars_.entries.fat), + protein=_macro_cell( + root, row, glyphs.PROTEIN, vars_.entries.protein, colors["protein"] + ), + carbs=_macro_cell( + root, row, glyphs.CARBS, vars_.entries.carbs, colors["carbs"] + ), + fat=_macro_cell(root, row, glyphs.FAT, vars_.entries.fat, colors["fat"]), ) return basis_prefix, per_entry, macros diff --git a/diet_guard/_gatelock_fillall.py b/diet_guard/_gatelock_fillall.py index bfc9c23..845012c 100644 --- a/diet_guard/_gatelock_fillall.py +++ b/diet_guard/_gatelock_fillall.py @@ -23,6 +23,7 @@ from importlib import import_module import queue from typing import TYPE_CHECKING, NamedTuple +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_fetch import FETCH_POLL_MS from diet_guard._gatelock_kuchnia import start_delivery_fetch from diet_guard._kuchnia_log import fill_plan, log_dishes @@ -41,15 +42,19 @@ if TYPE_CHECKING: # pragma: no cover - typing only from diet_guard._kuchnia_spread import SlottedDish #: The button's resting label; also what it returns to after each attempt. -FILL_LABEL = "🍱 Fill all" +FILL_LABEL = f"{g.FILL_ALL} {g.DELIVERY}" #: ``(unlock reason, nothing-new status)`` for the gate's post-write reconcile: -#: a fill that covered only later slots must not report "found in sync". +#: a fill that covered only later slots must not report "found in sync" -- +#: catering logged, and the food field is what the current slot still needs. RECONCILE_LABELS = ( - "Filled from catering", - "Catering logged; this slot still needs a meal.", + f"{g.OK} {g.DELIVERY}", + f"{g.OK} {g.DELIVERY} {g.FOOD}", ) +#: "Every slot already has a meal": nothing left for the fill to take. +_NOTHING_TO_FILL = f"{g.OK} {g.CLOCK}" + def _refresh_delivery(day: date) -> tuple[Sequence[Dish], str | None]: """Fetch ``day``'s dishes, importing the HTTP stack only when clicked. @@ -103,7 +108,7 @@ class FillAllFlow: def click(self) -> None: """First click fetches and proposes; a second click confirms.""" if self._demo_mode: - self._set_status("Fill all is only available on the real lock.") + self._set_status(f"{g.DEMO_ONLY} {g.FILL_ALL}") return if self._result is not None: return # a fetch is in flight; a double-click must not start two @@ -116,7 +121,7 @@ class FillAllFlow: # at midnight can never confirm yesterday's menu into today. self._reset() self._plan_day = today - self._set_status("Loading today's delivery…") + self._set_status(f"{g.BUSY} {g.DELIVERY}") self._result = start_delivery_fetch(_refresh_delivery, today) self._root.after(FETCH_POLL_MS, self._poll) @@ -132,24 +137,23 @@ class FillAllFlow: return self._result = None if outcome.reason is not None: - self._set_status(f"{outcome.reason} — still locked.", error=True) + self._set_status(f"{g.ERROR} {g.DELIVERY}", error=True) return if not outcome.dishes: - self._set_status("No catering delivery today.") + self._set_status(f"{g.EMPTY} {g.DELIVERY}") return plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule()))) if not plan: - self._set_status("Every slot already has a meal — nothing to fill.") + self._set_status(_NOTHING_TO_FILL) return self._plan = plan - self._label.set(f"✓ Confirm ({len(plan)})") + self._label.set(f"{g.OK} {len(plan)}") total = sum(item.dish.kcal for item in plan) - listing = ", ".join( + # The proposal: slot time and dish name (catering content) per row. + listing = " ".join( f"{slot_label(item.slot)} {item.dish.name}" for item in plan ) - self._set_status( - f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm." - ) + self._set_status(f"{g.DELIVERY} {len(plan)} {total:g} kcal {listing}") def _confirm(self) -> None: """Write the proposed dishes into the slots still empty right now.""" @@ -158,10 +162,10 @@ class FillAllFlow: plan = fill_plan(self._plan) self._reset() if not plan: - self._set_status("Every slot already has a meal — nothing to fill.") + self._set_status(_NOTHING_TO_FILL) return written = log_dishes(plan) - self._set_status(f"Logged {len(written)} catering dish(es).") + self._set_status(f"{g.OK} {g.DELIVERY} {len(written)}") self._on_logged() def _reset(self) -> None: diff --git a/diet_guard/_gatelock_glyphs.py b/diet_guard/_gatelock_glyphs.py new file mode 100644 index 0000000..63a5e2f --- /dev/null +++ b/diet_guard/_gatelock_glyphs.py @@ -0,0 +1,88 @@ +"""The PC gate's glyph vocabulary: one shape per concept, no words. + +kuhy's textgate rule (2026-11-08): nothing the gate paints carries a word. +Every Tk surface draws from this one table so a concept looks the same on the +Log Meal, History and Body tabs. Plain geometric BMP characters only, all in +DejaVu Sans: colour emoji can crash Tk 8.6 and the gate is the one window +that must never fail to draw. ``test_gate_no_words`` is the guard. + +The macro shapes match the phone app's ``MacroKind`` (protein circle, carbs +triangle, fat square), so one vocabulary reads the same on both devices. +""" + +from __future__ import annotations + +from typing import Final + +# Status cues: the status line shows one of these instead of a sentence. +OK: Final = "✓" +ERROR: Final = "✗" +BUSY: Final = "⋯" +EMPTY: Final = "∅" +DEMO_ONLY: Final = "⊘" +WARN: Final = "⚠" + +# Fields and concepts. +FOOD: Final = "✎" +AMOUNT: Final = "⚖" +PROTEIN: Final = "●" +CARBS: Final = "▲" +FAT: Final = "■" +MACRO_COLORS: Final = {"protein": "#6abf69", "carbs": "#e0a33a", "fat": "#6b9bef"} +BUDGET: Final = "⚑" +CLOCK: Final = "◷" +AVERAGE: Final = "⌀" +BIRTH: Final = "✱" +RESTING: Final = "☾" +IDEAL: Final = "◎" +STEPS: Final = "⇶" +MEASURED: Final = "◉" +TIMES: Final = "\N{MULTIPLICATION SIGN}" +ITEMS: Final = TIMES +GRAMS: Final = "g" + +# Actions. +SUBMIT: Final = "✓" +EDIT: Final = "✎" +SAVE: Final = "✓" +DELETE: Final = "⌫" +CLOSE: Final = "✕" +SYNC: Final = "⟳" +DELIVERY: Final = "▣" +FILL_ALL: Final = "⇊" +INFINITE: Final = "∞" +UNLOCKING: Final = "⇥" + +# Notebook tabs. +TAB_LOG: Final = "✚" +TAB_HISTORY: Final = "▦" +TAB_BODY: Final = "♙" + +# Profile sex, goal direction and goal span. +MALE: Final = "♂" +FEMALE: Final = "♀" +DIRECTION: Final = {"lose": "↓", "maintain": "=", "gain": "↑"} +SPAN: Final = {"day": "◔", "week": "◑", "month": "◕", "year": "●"} + +# Summaries: the year's tally, an empty value, and how an average compared +# with its budget (``AverageBand`` values; shape and fill grow with the excess). +YEAR_TOTAL: Final = "Σ" +DASH: Final = "\N{EN DASH}" +NO_VALUE: Final = DASH +BAND: Final = {"under": "✓", "slightly_over": "△", "very_over": "▲"} + +# Weekday header: weekdays small, the weekend larger (no day names). +WEEKDAY: Final = "•" +WEEKEND: Final = "●" +WEEKDAY_DOTS: Final = (WEEKDAY,) * 5 + (WEEKEND,) * 2 + + +def source_glyph(source: str) -> str: + """Where a nutrition came from: food bank, staple, catering, typed, looked up.""" + head = source.split(":", 1)[0].strip() + return { + "food bank": "▤", + "staple": "★", + "catering": DELIVERY, + "manual": FOOD, + }.get(head, "≈") diff --git a/diet_guard/_gatelock_layout.py b/diet_guard/_gatelock_layout.py index f9e47f6..6a50019 100644 --- a/diet_guard/_gatelock_layout.py +++ b/diet_guard/_gatelock_layout.py @@ -18,6 +18,7 @@ import tkinter as tk from gatelock import ButtonStyle, ScrollableSurface, make_button +from diet_guard import _gatelock_glyphs as glyphs from diet_guard._gatelock_fields import ( build_amount_row, build_desc, @@ -66,14 +67,7 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None: def _build_header(body: tk.Frame, vars_: GateVars) -> None: - """Pack the title and the current-slot header.""" - tk.Label( - body, - text="🍽 Diet Gate", - font=(_FONT, TITLE, "bold"), - bg=BG, - fg=_ACCENT, - ).pack(pady=(0, XS)) + """Pack the current-slot header (no title: the tab glyph names the tab).""" tk.Label( body, textvariable=vars_.slot_header, @@ -114,7 +108,7 @@ def _build_actions( make_button( body, _COLORS, - "Log & Continue", + glyphs.SUBMIT, callbacks.on_submit, ).pack(pady=(XS, XS)) @@ -129,7 +123,7 @@ def _build_actions( make_button( pulls, _COLORS, - "⟳ Fetch from sync", + glyphs.SYNC, callbacks.on_fetch_sync, ButtonStyle(variant="secondary", bold=False), ).pack(side="left", padx=(0, XS)) @@ -140,7 +134,7 @@ def _build_actions( make_button( pulls, _COLORS, - "🍱 Today's delivery", + glyphs.DELIVERY, callbacks.on_load_delivery, ButtonStyle(variant="secondary", bold=False), ).pack(side="left", padx=(0, XS)) @@ -222,10 +216,12 @@ def build_layout( make_button( root, _COLORS, - "✕ Close Demo", + glyphs.CLOSE, callbacks.on_close, ButtonStyle(variant="danger", bold=False), - ).place(x=10, y=10) + # Top-right: the glyph-only notebook tabs are narrow and sit top-left, + # where a button at the corner would cover them. + ).place(relx=1.0, x=-10, y=10, anchor="ne") return GateWidgets( frame=frame, diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index 56dcfd6..6ab3a33 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -15,6 +15,7 @@ from functools import partial import tkinter as tk from typing import TYPE_CHECKING +from diet_guard import _gatelock_glyphs as g from diet_guard._budget import BudgetError, daily_budget from diet_guard._budget_derived import protein_target_g from diet_guard._foodbank import remember_food @@ -92,13 +93,13 @@ class _GateMealFlow(_PullFlows): """Validate, then look the food up, or log it.""" description = self._get_desc() if not description: - self._set_status("Type what you ate first.", error=True) + self._set_status(f"{g.ERROR} {g.FOOD}", error=True) self._widgets.desc_text.focus_set() return values = self._macro_values() if values is None: - self._set_status("Macros must be numbers.", error=True) + self._set_status(f"{g.ERROR} {g.PROTEIN}{g.CARBS}{g.FAT}", error=True) self._widgets.macros.kcal.focus_set() return @@ -107,7 +108,7 @@ class _GateMealFlow(_PullFlows): return nutrition = self._current_nutrition() if nutrition is None: - self._set_status("Enter the calories, then submit.", error=True) + self._set_status(f"{g.ERROR} kcal", error=True) self._widgets.macros.kcal.focus_set() return self._record(description, nutrition) @@ -120,31 +121,28 @@ class _GateMealFlow(_PullFlows): up at its natural basis (per 100 g / serving); the amount eaten scales it, so the lookup never bakes in a portion. """ - self._set_status("looking up…") + self._set_status(g.BUSY) self.root.update_idletasks() candidates = lookup_candidates(description) if not candidates: - self._set_status( - "Couldn't look that up. Enter the calories yourself, then submit.", - error=True, - ) + # Not found: the kcal field takes focus, the cue names it. + self._set_status(f"{g.ERROR} {g.source_glyph('')} kcal", error=True) self._widgets.macros.kcal.focus_set() return self._show_candidates(candidates) self._apply_reference(candidates[0][1]) - source = candidates[0][1].source - tail = ( - "Review, or pick another below, then submit to log." - if len(candidates) > 1 - else "Review the values, then submit to log." - ) - self._set_status(f"Filled from {source}. {tail}") + # Filled, not logged: the source glyph, and the alternatives' count + # when the picker below offers more than one. + source = g.source_glyph(candidates[0][1].source) + more = f" {g.TIMES}{len(candidates)}" if len(candidates) > 1 else "" + self._set_status(f"{g.OK} {source}{more}") def _record(self, description: str, nutrition: Nutrition) -> None: """Log and bank a single food for the current slot, then advance.""" log_meal(description, nutrition, self._slot_for_log()) remember_food(description, nutrition) - self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})") + source = g.source_glyph(nutrition.source) + self._finish_slot(f"{nutrition.kcal:g} kcal {source}") def _slot_for_log(self) -> int | None: """Return the slot to tag a log with -- None in demo (satisfies no slot). @@ -165,7 +163,7 @@ class _GateMealFlow(_PullFlows): slot = self._pending[0] self._pending.pop(0) self._refresh_dashboard() - logged = f"Logged {slot_label(slot)}: {summary}" + logged = f"{g.OK} {slot_label(slot)} {summary}" if not self._pending: self._unlock(logged) return @@ -179,7 +177,7 @@ class _GateMealFlow(_PullFlows): if self._delivery_pending: self._prefill_next_dish(logged) return - self._set_status(f"{logged} — next meal, please.") + self._set_status(logged) self._widgets.desc_text.focus_set() def _unlock(self, logged: str) -> None: @@ -196,9 +194,8 @@ class _GateMealFlow(_PullFlows): d.name.strip().casefold() not in eaten for d in self._delivery_pending ) if left: - noun = "dish" if left == 1 else "dishes" - logged = f"{logged} ({left} more {noun} delivered; log with 'ate')" - self._set_status(f"{logged} — all meals logged, unlocking…") + logged = f"{logged} {g.DELIVERY} +{left}" + self._set_status(f"{logged} {g.UNLOCKING}") self.root.after(_UNLOCK_DELAY_MS, self.close) def _refresh_dashboard(self) -> None: @@ -212,14 +209,13 @@ class _GateMealFlow(_PullFlows): try: budget = daily_budget() except BudgetError, OSError: - return f"{consumed:g} kcal today" - return ( - f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" - ) + return f"{consumed:g} kcal" + left = round(budget - consumed, 1) + return f"{consumed:g} / {budget:g} kcal {g.BUDGET} {left:g}" def _dashboard_text(self) -> str: """Build the detail panel: recent meals, then macros and protein.""" - lines = ["── Today ───────────────────────────────"] + lines = ["────────────────────────────────────────"] entries = today_entries() if entries: for entry in entries[-_DASHBOARD_ROWS:]: @@ -230,20 +226,21 @@ class _GateMealFlow(_PullFlows): f"{entry_kcal(entry):>5.0f} kcal", ) else: - lines.append(" (nothing logged yet today)") + lines.append(f" {g.EMPTY}") protein, carbs, fat = today_total_macros() - lines.append(f" macros so far: P{protein:g} C{carbs:g} F{fat:g} g") + lines.append( + f" {g.PROTEIN} {protein:g} {g.CARBS} {carbs:g} {g.FAT} {fat:g} g" + ) target = protein_target_g() if target is not None: left = round(target - protein, 1) - lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") + lines.append( + f" {g.PROTEIN} {protein:g} / {target:g} g {g.BUDGET} {left:g} g" + ) return "\n".join(lines) def on_callback_error(self) -> None: """Surface an unexpected callback error without dropping the grab.""" - self._set_status( - "Something went wrong. Enter the calories, then submit again.", - error=True, - ) + self._set_status(f"{g.WARN} kcal", error=True) with contextlib.suppress(tk.TclError): self._widgets.macros.kcal.focus_set() diff --git a/diet_guard/_gatelock_nutrition.py b/diet_guard/_gatelock_nutrition.py index e2fe2ba..48166a1 100644 --- a/diet_guard/_gatelock_nutrition.py +++ b/diet_guard/_gatelock_nutrition.py @@ -14,6 +14,7 @@ from __future__ import annotations import tkinter as tk +from diet_guard import _gatelock_glyphs as g from diet_guard._budget import BudgetError, daily_budget from diet_guard._estimator import Nutrition, scale_nutrition from diet_guard._gatelock_core import _GateCore @@ -32,10 +33,13 @@ from diet_guard._state_today import ( def _format_preview(nutrition: Nutrition) -> str: """Render the one-line "this is what will be logged" preview.""" portion = f" · {nutrition.grams:g}g" if nutrition.grams else "" + # "staple: apple" -> the staple glyph plus the food name (content). + named = nutrition.source.partition(":")[2].strip() + source = f"{g.source_glyph(nutrition.source)} {named}".rstrip() return ( - f"→ {nutrition.kcal:g} kcal · " - f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}" - f"{portion} · {nutrition.source}" + f"→ {nutrition.kcal:g} kcal · {g.PROTEIN} {nutrition.protein_g:g} " + f"{g.CARBS} {nutrition.carbs_g:g} {g.FAT} {nutrition.fat_g:g}" + f"{portion} · {source}" ) @@ -98,14 +102,14 @@ class _GateNutrition(_GateCore): try: budget = daily_budget() except BudgetError, OSError: - tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" - self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}") + tail = f" + {this_kcal:g}" if this_kcal else "" + self._vars.projection.set(f"{consumed:g} kcal{tail}") return left = round(budget - consumed, 1) - base = f"Consumed {consumed:g} / {budget:g} kcal · {left:g} left" + base = f"{consumed:g} / {budget:g} kcal {g.BUDGET} {left:g}" if this_kcal: after = round(budget - consumed - this_kcal, 1) - self._vars.projection.set(f"{base} → after this item: {after:g} left") + self._vars.projection.set(f"{base} → {g.BUDGET} {after:g}") else: self._vars.projection.set(base) diff --git a/diet_guard/_gatelock_scheduleedit.py b/diet_guard/_gatelock_scheduleedit.py index cf60120..1f43c02 100644 --- a/diet_guard/_gatelock_scheduleedit.py +++ b/diet_guard/_gatelock_scheduleedit.py @@ -23,6 +23,7 @@ import abc import contextlib import tkinter as tk +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_budgetedit import _GateBudgetEdit from diet_guard._gatelock_ui import ERR, FG from diet_guard._meal_schedule import ( @@ -57,14 +58,14 @@ def schedule_problem(schedule: MealSchedule) -> str | None: a checkpoint. """ first, last = schedule.first_minute, schedule.last_minute + # Glyph cues, not sentences: the rule's numbers next to the field glyph. if last < first + SLOT_GRID_MINUTES: - return ( - f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first." - ) + return f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ {SLOT_GRID_MINUTES} min" if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT: - return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}." - if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1: - return "Too many meals for that window -- widen it or eat less often." + return f"{g.ERROR} {g.TIMES} {MIN_MEAL_COUNT}{g.DASH}{MAX_MEAL_COUNT}" + capacity = (last - first) // SLOT_GRID_MINUTES + 1 + if schedule.count > capacity: + return f"{g.ERROR} {g.TIMES} ≤ {capacity}" return None @@ -127,14 +128,14 @@ class _GateScheduleEdit(_GateBudgetEdit): if not self._cal_editing_schedule: self._cal_editing_schedule = True self._set_schedule_entry_state("normal") - self._set_schedule_button_text("Save") + self._set_schedule_button_text(g.SAVE) self._set_schedule_status("", error=False) return if not self._save_schedule_entry(): return self._cal_editing_schedule = False self._set_schedule_entry_state("readonly") - self._set_schedule_button_text("Edit") + self._set_schedule_button_text(g.EDIT) self._refresh_calendar() def _read_schedule_fields(self) -> MealSchedule | None: @@ -143,14 +144,12 @@ class _GateScheduleEdit(_GateBudgetEdit): first = parse_hhmm(self._cal_vars.schedule.first.get()) last = parse_hhmm(self._cal_vars.schedule.last.get()) except ValueError: - self._set_schedule_status( - "Enter meal times as HH:MM, e.g. 07:15.", error=True - ) + self._set_schedule_status(f"{g.ERROR} {g.CLOCK}", error=True) return None try: count = int(self._cal_vars.schedule.count.get().strip()) except ValueError: - self._set_schedule_status("Enter meals per day as a number.", error=True) + self._set_schedule_status(f"{g.ERROR} {g.TIMES}", error=True) return None return MealSchedule(first, last, count) @@ -169,5 +168,5 @@ class _GateScheduleEdit(_GateBudgetEdit): return False record_schedule_change(schedule) self._show_schedule(current_schedule()) - self._set_schedule_status("Saved.", error=False) + self._set_schedule_status(g.OK, error=False) return True diff --git a/diet_guard/_gatelock_ui.py b/diet_guard/_gatelock_ui.py index fb33d15..5d7780a 100644 --- a/diet_guard/_gatelock_ui.py +++ b/diet_guard/_gatelock_ui.py @@ -26,6 +26,7 @@ from gatelock import ( ) from diet_guard._gatelock_fillall import FILL_LABEL +from diet_guard._gatelock_glyphs import TIMES from diet_guard._gatelock_spacing import SM, XS from diet_guard._gatelock_typography import ( BODY, @@ -60,8 +61,8 @@ DEFAULT_PER_GRAMS = 100.0 UNIT_GRAMS = "grams" UNIT_ITEMS = "items" # Per-basis label prefixes for the two measuring modes. -BASIS_PREFIX_GRAMS = "Nutrition as on the label — per" -BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈" +BASIS_PREFIX_GRAMS = "/" +BASIS_PREFIX_ITEMS = f"{TIMES}1 ≈" # Wrap width for the gate's prose labels, in px. 640 is tokens.md's line-length # cap (rule 21, 40rem / ~65-70 characters). This was 900 at five call sites, # which both exceeded the cap and -- more practically -- forced a 900px-wide @@ -137,12 +138,16 @@ def _numeric_entry( def _macro_cell( - root: tk.Misc, row: tk.Frame, label: str, variable: tk.StringVar + root: tk.Misc, + row: tk.Frame, + label: str, + variable: tk.StringVar, + color: str = FG, ) -> tk.Entry: - """Pack one small labelled numeric entry into the macro row.""" + """Pack one numeric entry under its glyph (or unit) into the macro row.""" cell = tk.Frame(row, bg=BG) cell.pack(side="left", padx=SM) - tk.Label(cell, text=label, font=(_FONT, CAPTION), bg=BG, fg=FG).pack() + tk.Label(cell, text=label, font=(_FONT, CAPTION), bg=BG, fg=color).pack() entry = _numeric_entry(root, cell, width=7, variable=variable) entry.pack(ipady=XS) return entry diff --git a/diet_guard/tests/test_calendar_averages.py b/diet_guard/tests/test_calendar_averages.py index 0af6a7a..f62ebbe 100644 --- a/diet_guard/tests/test_calendar_averages.py +++ b/diet_guard/tests/test_calendar_averages.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import date +from diet_guard import _gatelock_glyphs as g from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule from diet_guard._calendar_view import averages_text @@ -15,40 +16,40 @@ def _flat(budget: int) -> BudgetSchedule: ) +_WEEK = f"{g.AVERAGE} {g.SPAN['week']} " +_MONTH = f"{g.AVERAGE} {g.SPAN['month']} " + + class TestAveragesText: def test_names_both_periods(self) -> None: text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "week" in text - assert "month" in text + assert _WEEK in text + assert _MONTH in text - def test_says_it_stops_at_yesterday(self) -> None: + def test_empty_log_reads_no_value_not_zero(self) -> None: text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "to yesterday" in text - - def test_empty_log_reads_no_data_not_zero(self) -> None: - text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "no data" in text + assert text == f"{_WEEK}{g.NO_VALUE} {_MONTH}{g.NO_VALUE}" assert "0 kcal" not in text def test_renders_the_average_and_its_band(self) -> None: # Mon-Tue of the week containing Wed 2026-01-14, at 3000 kcal each - # against a 2000 budget: 150% of budget, comfortably "very over". + # against a 2000 budget: 150% of budget, comfortably very over. log = { "2026-01-12": [{"kcal": 3000.0}], "2026-01-13": [{"kcal": 3000.0}], } text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "week 3000 kcal (very over)" in text + assert f"{_WEEK}3000 kcal {g.BAND['very_over']}" in text - def test_under_budget_reads_under(self) -> None: + def test_under_budget_band(self) -> None: log = {"2026-01-12": [{"kcal": 1500.0}]} text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "week 1500 kcal (under)" in text + assert f"{_WEEK}1500 kcal {g.BAND['under']}" in text - def test_slightly_over_reads_slightly_over(self) -> None: + def test_slightly_over_band(self) -> None: log = {"2026-01-12": [{"kcal": 2200.0}]} text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14)) - assert "week 2200 kcal (slightly over)" in text + assert f"{_WEEK}2200 kcal {g.BAND['slightly_over']}" in text def test_today_is_not_averaged_in(self) -> None: log = {"2026-01-14": [{"kcal": 9000.0}]} @@ -56,4 +57,4 @@ class TestAveragesText: assert "9000" not in text def test_defaults_to_the_real_today(self) -> None: - assert "week" in averages_text({}, schedule=_flat(2000)) + assert _WEEK in averages_text({}, schedule=_flat(2000)) diff --git a/diet_guard/tests/test_calendar_view.py b/diet_guard/tests/test_calendar_view.py index b1ee906..d3d6ee6 100644 --- a/diet_guard/tests/test_calendar_view.py +++ b/diet_guard/tests/test_calendar_view.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import date +from diet_guard import _gatelock_glyphs as g from diet_guard._calendar_view import ( CalendarCell, build_month_cells, @@ -103,23 +104,22 @@ class TestCellStyle: class TestStreaksText: - """:func:`streaks_text` boundary cases (singular/plural, zero).""" + """:func:`streaks_text` boundary cases (zero, one).""" - def test_zero_streaks_render_as_zero_days(self) -> None: + def test_zero_streaks_render_as_zeros(self) -> None: text = streaks_text({}, today=date(2026, 7, 13)) - assert "0 days" in text + assert text == f"{g.FOOD} 0 {g.OK} 0" - def test_a_one_day_streak_uses_the_singular(self) -> None: + def test_a_one_day_streak(self) -> None: text = streaks_text( {"2026-07-13": DayStatus.GREEN}, today=date(2026, 7, 13), ) - assert "Logging streak: 1 day " in text - assert "Adherence streak: 1 day" in text + assert text == f"{g.FOOD} 1 {g.OK} 1" class TestYtdText: - """:func:`ytd_text` renders the tally's three counts.""" + """:func:`ytd_text` renders the tally's counts.""" def test_reports_logged_elapsed_and_adherent_counts(self) -> None: status_map_ = { @@ -127,5 +127,4 @@ class TestYtdText: "2026-01-02": DayStatus.RED, } text = ytd_text(status_map_, today=date(2026, 1, 10)) - assert "logged 2/10 days" in text - assert "1 within budget" in text + assert text == f"{g.YEAR_TOTAL} {g.FOOD} 2/10 {g.OK} 1" diff --git a/diet_guard/tests/test_gate_no_words.py b/diet_guard/tests/test_gate_no_words.py new file mode 100644 index 0000000..c63eb36 --- /dev/null +++ b/diet_guard/tests/test_gate_no_words.py @@ -0,0 +1,191 @@ +"""Guard: nothing the PC gate paints carries a word (kuhy's textgate rule). + +Every Tk surface of the gate -- Log Meal, History and Body tabs, their status +lines, buttons, notebook tabs and canvas captions -- must show glyphs, numbers +and unit symbols only. Text the user wrote (typed entries, logged food names) +is content and stays; the fake ``Entry``/``Text``/``Listbox`` widgets that hold +it are not recorded at all. + +The recorder hooks the fake widgets themselves, so it sees what the gate +*built* and what it *later set*: ``text=`` at construction, ``config(text=)`` +afterwards, every value written into a variable some widget displays, the +values of an ``OptionMenu``, notebook tab titles and canvas ``create_text``. +It fails closed: a run that recorded too little (a module escaped the fake tk) +is red, and a canary proves one injected word turns the check red. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import re +from typing import TYPE_CHECKING +from unittest.mock import patch + +from diet_guard._budget import write_budget +from diet_guard._gatelock import MealGate +from diet_guard._state import log_meal +from diet_guard.tests._gate_fixtures import fake_tk +from diet_guard.tests._tk_fakes import FakeCanvas, FakeNotebook, FakeVar, FakeWidget +from diet_guard.tests.conftest import _nutrition + +if TYPE_CHECKING: + from collections.abc import Iterator + +# Unit symbols next to a number, and the one method acronym kuhy kept (DOUBT +# rows in the rework's keep.md). Anything else of 2+ letters is a word. +UNITS = frozenset({"kcal", "kg", "lb", "st", "cm", "km", "min", "MET"}) +# Food names logged by the scenario below: user content, shown on purpose. +CONTENT = frozenset({"apple"}) +WORD = re.compile(r"[^\W\d_]{2,}") + + +def words_in(text: object) -> list[str]: + """Every 2+ letter word in ``text`` that is neither a unit nor content.""" + return [w for w in WORD.findall(str(text)) if w not in UNITS | CONTENT] + + +class Recorder: + """Collects every string the fake widgets would paint.""" + + def __init__(self) -> None: + self.widgets: list[FakeWidget] = [] + self.shown_vars: list[FakeVar] = [] + self.var_history: dict[int, list[str]] = {} + self.tabs: list[str] = [] + self.canvas_text: list[str] = [] + self.menu_values: list[str] = [] + + def texts(self) -> list[str]: + """All painted strings, history included (a status shown once counts).""" + out = [str(w.configured.get("text", "")) for w in self.widgets] + for var in self.shown_vars: + out.extend(self.var_history.get(id(var), [var.get()])) + return [*out, *self.tabs, *self.canvas_text, *self.menu_values] + + def offenders(self) -> list[str]: + """The painted strings that hold a word.""" + return sorted({t for t in self.texts() if words_in(t)}) + + +@contextmanager +def recording() -> Iterator[Recorder]: + """Hook the fake widgets so every painted string lands in a recorder.""" + rec = Recorder() + widget_init, var_init, var_set = ( + FakeWidget.__init__, + FakeVar.__init__, + FakeVar.set, + ) + tab_add, canvas_text = FakeNotebook.add, FakeCanvas.create_text + + def init(self: FakeWidget, *args: object, **kwargs: object) -> None: + widget_init(self, *args, **kwargs) + rec.widgets.append(self) + shown = kwargs.get("textvariable") + if isinstance(shown, FakeVar): + rec.shown_vars.append(shown) + if len(args) > 1 and isinstance(args[1], FakeVar): # tk.OptionMenu + rec.shown_vars.append(args[1]) + rec.menu_values.extend(str(v) for v in args[2:]) + + def vinit(self: FakeVar, master: object = None, value: str = "") -> None: + var_init(self, master, value) + rec.var_history[id(self)] = [value] + + def vset(self: FakeVar, value: str) -> None: + var_set(self, value) + rec.var_history.setdefault(id(self), []).append(str(value)) + + def add(self: FakeNotebook, child: object, *, text: str = "") -> None: + tab_add(self, child, text=text) + rec.tabs.append(text) + + def ctext(self: FakeCanvas, *args: object, **kwargs: object) -> int: + rec.canvas_text.append(str(kwargs.get("text", ""))) + return canvas_text(self, *args, **kwargs) + + with ( + patch.object(FakeWidget, "__init__", init), + patch.object(FakeVar, "__init__", vinit), + patch.object(FakeVar, "set", vset), + patch.object(FakeNotebook, "add", add), + patch.object(FakeCanvas, "create_text", ctext), + ): + yield rec + + +def _drive(gate: MealGate) -> None: + """Walk every user-reachable state that writes a status or caption.""" + gate._on_submit() # nothing typed + gate._set_desc("apple") + gate._set_entry(gate._widgets.macros.protein, "x") + gate._on_submit() # macros not numbers + gate._set_entry(gate._widgets.macros.protein, "") + gate._on_submit() # kcal missing + for click in (gate._on_fill_all, gate._on_fetch_sync, gate._on_load_delivery): + click() # demo refusals + write_budget(2000, weight_kg=80) + log_meal("apple", _nutrition(95, 100), 8) + gate._pending = [480, 720] + gate._refresh_slot_header() + gate._pending = [] + gate._refresh_slot_header() + gate._refresh_dashboard() + gate._refresh_projection() + gate._refresh_calendar() + gate._on_prev_month() + gate._on_next_month() + for value in ("x", "0", "1900"): + gate._on_edit_or_save_budget() + gate._cal_vars.budget.set(value) + gate._on_edit_or_save_budget() + gate._on_edit_or_save_schedule() + gate._cal_vars.schedule.count.set("x") + gate._on_edit_or_save_schedule() + gate._cal_vars.schedule.count.set("4") + gate._on_edit_or_save_schedule() + + +def _drive_body(gate: MealGate) -> None: + """The Body tab's rejects, graph ranges and a saved reading.""" + tab = gate._body_tab + tab.save_profile() + tab.vars.logs.weight.set("5") + tab.save_weight() + tab.vars.logs.weight_day.set("nope") + tab.delete_weight() + tab.vars.logs.body_fat.set("90") + tab.save_body_fat() + tab.delete_body_fat() + tab.set_range(30) + tab.vars.profile.birth.set("2000-05-22") + tab.vars.profile.height.set("169") + tab.vars.profile.sex.set("m") + tab.save_profile() + tab.vars.logs.weight.set("72.5") + tab.vars.logs.weight_day.set("2026-09-10") + tab.save_weight() + tab.set_range(None) + tab.goal_changed() + + +def test_the_gate_paints_no_words() -> None: + """Build and drive every tab; no painted string may hold a word.""" + with recording() as rec, fake_tk(): + built = MealGate(demo_mode=True) + _drive(built) + _drive_body(built) + assert len(rec.tabs) >= 3, "the notebook tabs were not recorded" + assert len(rec.widgets) > 40, "too few widgets recorded: a module escaped the fake" + assert rec.canvas_text, "the Body graph drew nothing" + offenders = rec.offenders() + assert not offenders, "\n" + "\n".join(map(repr, offenders)) + + +def test_the_guard_catches_an_injected_word() -> None: + """Canary: the checker is red on a single word, and quiet on units/content.""" + with recording() as rec: + FakeWidget(None, text="Save") + FakeWidget(None, text="✓ 1900 kcal apple") + assert rec.offenders() == ["Save"] + assert words_in("08:00 · 2/4 72.5 kg") == [] diff --git a/diet_guard/tests/test_gate_no_words_static.py b/diet_guard/tests/test_gate_no_words_static.py new file mode 100644 index 0000000..1b379fd --- /dev/null +++ b/diet_guard/tests/test_gate_no_words_static.py @@ -0,0 +1,74 @@ +"""Static twin of ``test_gate_no_words``: no gate source line can paint a word. + +The fake-tk guard only sees strings on the paths a scenario drives. This one +parses every ``_gatelock*.py`` module and fails on any string literal handed +to a painted keyword (``text=``, ``notebook.add(..., text=)``) or written into +a status variable that holds a 2+ letter word, so a new sentence on an +unexercised branch cannot slip in. Runs in CI (no display, no installed gate). +""" + +from __future__ import annotations + +import ast +from pathlib import Path +import re + +from diet_guard.tests.test_gate_no_words import UNITS + +PACKAGE = Path(__file__).resolve().parent.parent +WORD = re.compile(r"[^\W\d_]{2,}") +# The window title is the OS-level app name: the one kept proper noun. +APP_NAME = "Diet Gate" +# Variable-backed text sinks: StringVar writes and status setters. +SINKS = {"set", "_set_status", "_set_budget_status", "_set_schedule_status"} + + +def _literals(node: ast.AST) -> list[str]: + """Every constant string inside ``node`` (f-string parts included).""" + return [ + n.value + for n in ast.walk(node) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + ] + + +def _offences(path: Path) -> list[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + found: list[str] = [] + for call in (n for n in ast.walk(tree) if isinstance(n, ast.Call)): + painted: list[ast.AST] = [k.value for k in call.keywords if k.arg == "text"] + name = getattr(call.func, "attr", getattr(call.func, "id", "")) + if name in SINKS: + painted.extend(call.args[:1]) + if name == "title": + continue # the window title: APP_NAME, checked below + for node in painted: + found.extend( + f"{path.name}:{call.lineno} {lit!r}" + for lit in _literals(node) + if set(WORD.findall(lit)) - UNITS + ) + return found + + +def test_no_gate_source_paints_a_word() -> None: + paths = sorted(PACKAGE.glob("_gatelock*.py")) + assert len(paths) > 20, "the gate modules were not found" + offences = [o for path in paths for o in _offences(path)] + assert not offences, "\n" + "\n".join(offences) + + +def test_the_checker_flags_a_sentence(tmp_path: Path) -> None: + """Canary: a status sentence and a ``text=`` word are both caught.""" + bad = tmp_path / "_gatelock_x.py" + bad.write_text( + 'def f(v, w):\n v.set("Saved.")\n w.Label(text="Edit")\n' + ' v.set("OK")\n', + encoding="utf-8", + ) + assert len(_offences(bad)) == 3 # "OK" is two letters: a word too + + +def test_the_window_title_is_the_app_name() -> None: + source = (PACKAGE / "_gatelock.py").read_text(encoding="utf-8") + assert f'self.root.title("{APP_NAME}")' in source diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index d447c9b..c244127 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -19,6 +19,7 @@ from diet_guard import ( _gatelock, _gatelock_support, ) +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock import ( MealGate, _pending_slots, @@ -162,7 +163,7 @@ class TestLockDelegation: def test_callback_error_status(self, gate: MealGate) -> None: """An unexpected callback error surfaces a recoverable message.""" gate.on_callback_error() - assert "went wrong" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.WARN} kcal" def test_run_delegates_to_lock(self, gate: MealGate) -> None: """run() hands off to the owned LockWindow.""" diff --git a/diet_guard/tests/test_gatelock_body.py b/diet_guard/tests/test_gatelock_body.py index 5a388ce..1ecf3b4 100644 --- a/diet_guard/tests/test_gatelock_body.py +++ b/diet_guard/tests/test_gatelock_body.py @@ -12,6 +12,7 @@ from unittest.mock import patch import pytest from diet_guard import _gatelock_body +from diet_guard import _gatelock_glyphs as g from diet_guard._body_fat_store import body_fats from diet_guard._body_prefs import saved_goal from diet_guard._body_store import profile, set_profile, set_weight, weights @@ -60,20 +61,20 @@ class TestProfile: def test_rejects_then_saves(self, tab: BodyTab) -> None: tab.vars.profile.height.set("9") tab.save_profile() - assert tab.vars.status.get().startswith("Profile needs") + assert tab.vars.status.get().startswith(g.ERROR) tab.vars.profile.birth.set("2000-05-22") tab.vars.profile.height.set("169") tab.vars.profile.sex.set("m") tab.save_profile() assert profile().complete - assert tab.vars.status.get().startswith("Profile saved") + assert tab.vars.status.get() == g.OK def test_complete_profile_loads_into_the_inputs(self) -> None: _person() with fake_tk(): body = BodyTab(FakeNotebook()) assert body.vars.profile.height.get() == "169" - assert body.vars.goal.answer.get().startswith("eat ") + assert body.vars.goal.answer.get().startswith("→ ") class TestWeights: @@ -81,7 +82,7 @@ class TestWeights: write_budget(2000, weight_kg=90.0) tab.vars.logs.weight.set("5") tab.save_weight() - assert tab.vars.status.get().startswith("Weight needs") + assert tab.vars.status.get().startswith(g.ERROR) tab.vars.logs.weight.set("80") tab.vars.logs.weight_day.set("2026-09-10") tab.save_weight() @@ -89,7 +90,7 @@ class TestWeights: assert (read_raw_record() or {})["w"] == 80.0 tab.vars.logs.weight_day.set("nope") tab.delete_weight() - assert tab.vars.status.get().startswith("Type the") + assert tab.vars.status.get() == g.ERROR + " " + g.DELETE + " 0000-00-00" tab.vars.logs.weight_day.set("2026-09-10") tab.delete_weight() assert weights() == {} @@ -97,13 +98,13 @@ class TestWeights: def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None: tab.vars.logs.body_fat.set("90") tab.save_body_fat() - assert tab.vars.status.get().startswith("Body fat needs") + assert tab.vars.status.get().startswith(g.ERROR) tab.vars.logs.body_fat.set("17") tab.save_body_fat() assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0} tab.vars.logs.fat_day.set("x") tab.delete_body_fat() - assert tab.vars.status.get().startswith("Type the") + assert tab.vars.status.get() == g.ERROR + " " + g.DELETE + " 0000-00-00" tab.vars.logs.fat_day.set(next(iter(body_fats()))) tab.delete_body_fat() assert body_fats() == {} @@ -118,7 +119,7 @@ class TestGoalAndGraph: assert saved_goal().weight_unit == "lb" assert saved_goal().amount == 0.5 # unreadable amount kept assert tab.vars.goal.amount.get() == "0.5" - assert tab.vars.goal.answer.get().startswith("eat ") + assert tab.vars.goal.answer.get().startswith("→ ") def test_range_and_body_fat_graph(self, tab: BodyTab) -> None: _person() @@ -127,7 +128,7 @@ class TestGoalAndGraph: tab.set_range(None) tab.vars.logs.graph.set("fat") tab.refresh() - assert "Mon" in tab.vars.blocks["plan"].get() + assert f"2500 {g.EDIT}" in tab.vars.blocks["plan"].get() class TestGraph: @@ -150,6 +151,7 @@ class TestGraph: box = GraphBox(700, 300) draw_weight_graph(canvas, {}, box, None) assert canvas.drawn[0][0] == "text" + assert canvas.drawn[0][2]["text"] == g.EMPTY log = {"2026-09-20": 74.0, "2026-09-27": 72.0} draw_weight_graph(canvas, log, box, (52.8, 71.1)) kinds = [kind for kind, _, _ in canvas.drawn] diff --git a/diet_guard/tests/test_gatelock_body_glyphs.py b/diet_guard/tests/test_gatelock_body_glyphs.py new file mode 100644 index 0000000..9a3c966 --- /dev/null +++ b/diet_guard/tests/test_gatelock_body_glyphs.py @@ -0,0 +1,166 @@ +"""The gate's glyph twins of the Body report blocks, and the glyph table.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +import pytest + +from diet_guard import _gatelock_glyphs as g +from diet_guard._activity_kcal import Session +from diet_guard._body_activity_store import set_activity +from diet_guard._body_fat_store import set_body_fat +from diet_guard._body_prefs import goal_from_json +from diet_guard._body_store import read_body, set_profile, set_weight, write_body +from diet_guard._body_view import build_view +from diet_guard._gatelock_body_glyphs import ( + ACTIVITY, + _band, + activity_glyphs, + bmi_glyphs, + bmr_glyphs, + bodyfat_glyphs, + ideal_glyphs, + plan_glyphs, + profile_glyphs, + target_glyphs, +) +from diet_guard._week_plan import plan_week + +_TODAY = date(2026, 9, 28) + + +def _person() -> None: + set_profile(birth="2000-05-22", height_cm=169.0, sex="m") + set_weight("2026-09-27", 72.6) + + +class TestEmptyState: + def test_every_block_has_a_placeholder_not_a_sentence(self) -> None: + view = build_view(_TODAY) + assert profile_glyphs(view, _TODAY) == [ + f"{g.BIRTH} {g.NO_VALUE} {g.NO_VALUE} cm {g.NO_VALUE}", + f"{g.AMOUNT} {g.NO_VALUE} kg", + ] + assert bodyfat_glyphs(view) == [f"% {g.NO_VALUE}"] + assert bmi_glyphs(view) == [f"{g.TAB_BODY} ?"] + assert ideal_glyphs(view) == [f"{g.IDEAL} ?"] + assert bmr_glyphs(view) == [f"{g.RESTING} ?"] + assert target_glyphs(view) == ["?"] + assert activity_glyphs(view) == [f"{g.MEASURED} {g.EMPTY}"] + + +class TestWithData: + def test_profile_bmi_ideal_and_bmr(self) -> None: + _person() + set_body_fat("2026-09-27", 18.5) + view = build_view(_TODAY) + assert profile_glyphs(view, _TODAY)[0].startswith(f"{g.BIRTH} 2000-05-22 (26)") + assert profile_glyphs(view, _TODAY)[1].startswith(f"{g.AMOUNT} 72.6 kg") + assert bodyfat_glyphs(view)[0].startswith("18.5%") + assert bmi_glyphs(view)[0].startswith(f"{g.TAB_BODY} 25.4 △") + assert ideal_glyphs(view)[0].startswith(f"{g.IDEAL} ") + assert bmr_glyphs(view)[0].startswith(f"{g.RESTING} ") + + def test_target_and_the_floor_warning(self) -> None: + _person() + view = build_view(_TODAY) + assert view.numbers is not None + assert target_glyphs(view)[0].startswith("= ") + low = replace( + view, + numbers=replace( + view.numbers, + target=replace(view.numbers.target, kcal=900), + ), + ) + assert target_glyphs(low)[-1].startswith(g.WARN) + + def test_an_unanswered_measured_goal_shows_the_empty_mark(self) -> None: + _person() + goal = goal_from_json({"act": "measured:MET"}) + view = build_view(_TODAY, goal) + assert target_glyphs(view) == [f"{g.MEASURED} {g.EMPTY}"] + + def test_activity_means_steps_and_sessions(self) -> None: + _person() + set_activity( + ( + Session("2026-09-22", "run", 60.0, 6.0, "Run", "runnerup"), + Session("2026-09-25", "strength", 80.0, None, "SL", "screen-locker"), + ) + ) + doc = read_body() + doc["steps"] = {"2026-09-26": {"n": 9000, "t": "2026-09-26T22:00:00+02:00"}} + write_body(doc) + lines = activity_glyphs(build_view(_TODAY)) + assert lines[0].startswith(g.SYNC) + assert lines[1].startswith(f"{g.AVERAGE} 6") + assert lines[2].startswith(f"{g.STEPS} 9000") + assert any(" run " in line and "6.0 km" in line for line in lines) + assert any("MET" in line and "↗" in line and "/km" in line for line in lines) + + def test_sessions_without_a_weight_carry_no_kcal(self) -> None: + set_activity((Session("2026-09-22", "run", 60.0, None, "Run", "runnerup"),)) + assert activity_glyphs(build_view(_TODAY))[1].endswith("min") + + def test_steps_alone_have_a_dash_for_the_stamp(self) -> None: + _person() + doc = read_body() + doc["steps"] = {"2026-09-27": {"n": 3000, "t": "2026-09-27T22:00:00+02:00"}} + write_body(doc) + assert activity_glyphs(build_view(_TODAY))[0] == f"{g.SYNC} {g.NO_VALUE}" + + +class TestBand: + @pytest.mark.parametrize( + ("category", "glyph"), + [ + ("severe thinness", "▽"), + ("normal", g.OK), + ("overweight", "△"), + ("obese class II", "▲"), + ("adult categories do not apply under 20", g.NO_VALUE), + ], + ) + def test_each_who_band_has_its_own_shape(self, category: str, glyph: str) -> None: + assert _band(category) == glyph + + +class TestPlan: + def test_sources_and_the_remaining_line(self) -> None: + low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {}) + lines = plan_glyphs(low) + assert lines[0] == f"{g.AVERAGE} 1500 kcal" + assert g.EDIT not in lines[1] + assert "?" in lines[2] + assert lines[-1].endswith(g.WARN) + + def test_a_fully_known_week_reports_its_average(self) -> None: + full = plan_week(date(2026, 10, 4), 1800, {}, dict.fromkeys(range(7), 1900)) + assert plan_glyphs(full)[-1] == f"{g.OK} {g.AVERAGE} 1900 kcal" + + def test_the_remaining_line_without_a_warning(self) -> None: + plan = plan_week(date(2026, 10, 1), 2200, {}, {}) + assert plan_glyphs(plan)[-1].startswith("→") + assert g.WARN not in plan_glyphs(plan)[-1] + + +class TestGlyphTable: + def test_activity_picker_covers_every_choice(self) -> None: + assert ACTIVITY["sedentary"] == "▁" + assert ACTIVITY["measured:ACSM"] == f"{g.MEASURED} ↗" + + @pytest.mark.parametrize( + ("source", "glyph"), + [ + ("food bank", "▤"), + ("staple: apple", "★"), + ("catering", g.DELIVERY), + ("manual", g.FOOD), + ("openfoodfacts", "≈"), + ], + ) + def test_every_nutrition_source_has_a_glyph(self, source: str, glyph: str) -> None: + assert g.source_glyph(source) == glyph diff --git a/diet_guard/tests/test_gatelock_calendar.py b/diet_guard/tests/test_gatelock_calendar.py index 9852fa5..5af52df 100644 --- a/diet_guard/tests/test_gatelock_calendar.py +++ b/diet_guard/tests/test_gatelock_calendar.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from unittest.mock import patch from diet_guard import _budget +from diet_guard import _gatelock_glyphs as g from diet_guard._budget import daily_budget, write_budget from diet_guard._budget_derived import budget_weight from diet_guard._state import log_meal @@ -21,7 +22,7 @@ class TestNotebookWiring: def test_builds_a_notebook_with_all_three_tabs(self, gate: MealGate) -> None: labels = [text for _child, text in gate._notebook.tabs] - assert labels == ["Log Meal", "History", "Body"] + assert labels == [g.TAB_LOG, g.TAB_HISTORY, g.TAB_BODY] def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None: assert 1 <= gate._cal_month <= 12 @@ -64,8 +65,8 @@ class TestBudgetDefaultAndDegradation: gate: MealGate, ) -> None: gate._refresh_calendar() - assert "Logging streak" in gate._cal_vars.streaks.get() - assert "This year" in gate._cal_vars.ytd.get() + assert gate._cal_vars.streaks.get().startswith(g.FOOD) + assert gate._cal_vars.ytd.get().startswith(g.YEAR_TOTAL) def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None: gate._refresh_calendar() @@ -75,7 +76,7 @@ class TestBudgetDefaultAndDegradation: assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None: - assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" + assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.EDIT def test_no_budget_set_still_renders_every_grid_cell( self, @@ -90,7 +91,7 @@ class TestBudgetDefaultAndDegradation: log_meal("oatmeal", _nutrition(kcal=300), None) gate._refresh_calendar() assert gate._cal_vars.budget.get() == "1800" - assert "Logging streak" in gate._cal_vars.streaks.get() + assert gate._cal_vars.streaks.get().startswith(g.FOOD) def test_a_corrupt_budget_file_degrades_with_an_error_message( self, @@ -98,7 +99,7 @@ class TestBudgetDefaultAndDegradation: ) -> None: _budget.BUDGET_FILE.write_text("not json") gate._refresh_calendar() - assert "corrupt" in gate._cal_vars.streaks.get() + assert gate._cal_vars.streaks.get() == f"{g.WARN} {g.BUDGET}" assert gate._cal_vars.ytd.get() == "" @@ -144,7 +145,7 @@ class TestBudgetEditToggle: ) -> None: gate._on_edit_or_save_budget() assert gate._cal_editing_budget is True - assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" + assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.SAVE assert gate._cal_widgets.budget_entry.configured.get("state") == "normal" def test_second_click_with_non_numeric_input_stays_in_edit_mode( @@ -155,8 +156,8 @@ class TestBudgetEditToggle: self._type(gate, "not a number") gate._on_edit_or_save_budget() assert gate._cal_editing_budget is True - assert "whole number" in gate._cal_vars.budget_status.get() - assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save" + assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal" + assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.SAVE def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode( self, @@ -166,13 +167,13 @@ class TestBudgetEditToggle: self._type(gate, "0") gate._on_edit_or_save_budget() assert gate._cal_editing_budget is True - assert "positive" in gate._cal_vars.budget_status.get() + assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal > 0" def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None: gate._on_edit_or_save_budget() self._type(gate, "-500") gate._on_edit_or_save_budget() - assert "positive" in gate._cal_vars.budget_status.get() + assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal > 0" def test_second_click_with_a_valid_value_saves_and_exits_edit_mode( self, @@ -182,8 +183,8 @@ class TestBudgetEditToggle: self._type(gate, "1900") gate._on_edit_or_save_budget() assert gate._cal_editing_budget is False - assert gate._cal_vars.budget_status.get() == "Saved." - assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit" + assert gate._cal_vars.budget_status.get() == g.OK + assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.EDIT assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly" assert daily_budget() == 1900 diff --git a/diet_guard/tests/test_gatelock_dashboard.py b/diet_guard/tests/test_gatelock_dashboard.py index cd4bf91..5888f2c 100644 --- a/diet_guard/tests/test_gatelock_dashboard.py +++ b/diet_guard/tests/test_gatelock_dashboard.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, patch from diet_guard import _gatelock_delivery +from diet_guard import _gatelock_glyphs as g from diet_guard._budget import write_budget from diet_guard._state import log_meal from diet_guard.tests.conftest import _nutrition @@ -28,12 +29,12 @@ class TestDashboard: """A sealed budget shows consumed/target/remaining.""" write_budget(2000) gate._refresh_dashboard() - assert "left" in gate._vars.cal_headline.get() + assert gate._vars.cal_headline.get() == f"0 / 2000 kcal {g.BUDGET} 2000" def test_headline_without_budget(self, gate: MealGate) -> None: """With no budget, only today's total is shown.""" gate._refresh_dashboard() - assert "kcal today" in gate._vars.cal_headline.get() + assert gate._vars.cal_headline.get() == "0 kcal" def test_dashboard_lists_entries(self, gate: MealGate) -> None: """Logged entries appear in the detail panel.""" @@ -42,31 +43,31 @@ class TestDashboard: gate._refresh_dashboard() text = gate._vars.dashboard.get() assert "apple" in text - assert "protein" in text + assert f"{g.PROTEIN} 10 /" in text def test_dashboard_empty(self, gate: MealGate) -> None: """With nothing logged, the panel says so.""" gate._refresh_dashboard() - assert "nothing logged yet" in gate._vars.dashboard.get() + assert g.EMPTY in gate._vars.dashboard.get() def test_slot_header_variants(self, gate: MealGate) -> None: """The header covers none / one / several pending slots.""" gate._pending = [] gate._refresh_slot_header() - assert "All meals logged" in gate._vars.slot_header.get() + assert gate._vars.slot_header.get() == g.OK gate._pending = [480] gate._refresh_slot_header() - assert "Log your" in gate._vars.slot_header.get() + assert gate._vars.slot_header.get() == f"{g.CLOCK} 08:00" gate._pending = [480, 720] gate._refresh_slot_header() - assert "remaining" in gate._vars.slot_header.get() + assert gate._vars.slot_header.get() == f"{g.CLOCK} 08:00 1/2" def test_projection_with_budget(self, gate: MealGate) -> None: """The projection shows the after-this-item remaining when priced.""" write_budget(2000) gate._widgets.macros.kcal.insert(0, "300") gate._refresh_projection() - assert "after this item" in gate._vars.projection.get() + assert gate._vars.projection.get().endswith(f"→ {g.BUDGET} 1700") class TestSlotWalk: @@ -121,7 +122,7 @@ class TestFetchFromSync: with patch.object(_gatelock_delivery, "pull_peer_logs") as pull: gate._on_fetch_sync() pull.assert_not_called() - assert "only available on the real lock" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.DEMO_ONLY} {g.SYNC}" def test_pull_failure_keeps_lock(self, gate: MealGate) -> None: """A failed pull shows the reason and leaves pending slots intact.""" @@ -135,7 +136,7 @@ class TestFetchFromSync: gate._on_fetch_sync() _drive_fetch(gate) assert gate._pending == [480, 720] - assert "still locked" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.ERROR} {g.SYNC}" def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None: """A clean pull that satisfies nothing reports so and keeps the slots.""" @@ -148,7 +149,7 @@ class TestFetchFromSync: gate._on_fetch_sync() _drive_fetch(gate) assert gate._pending == [480, 720] - assert "No new meals" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.EMPTY} {g.SYNC}" def test_partial_advances_to_next_slot(self, gate: MealGate) -> None: """One slot pulled in leaves the rest; the window advances (singular).""" @@ -161,10 +162,10 @@ class TestFetchFromSync: gate._on_fetch_sync() _drive_fetch(gate) assert gate._pending == [720] - assert "Pulled 1 meal " in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.OK} {g.SYNC} 1" - def test_partial_plural_wording(self, gate: MealGate) -> None: - """Two slots pulled in uses the plural 'meals'.""" + def test_partial_shows_the_count(self, gate: MealGate) -> None: + """Two slots pulled in shows the count 2.""" gate.demo_mode = False gate._pending = [480, 720, 960] with ( @@ -174,7 +175,7 @@ class TestFetchFromSync: gate._on_fetch_sync() _drive_fetch(gate) assert gate._pending == [960] - assert "Pulled 2 meals" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.OK} {g.SYNC} 2" def test_all_satisfied_unlocks(self, gate: MealGate) -> None: """When the pull satisfies every pending slot, the gate unlocks.""" @@ -187,7 +188,7 @@ class TestFetchFromSync: gate._on_fetch_sync() _drive_fetch(gate) assert gate._pending == [] - assert "unlocking" in gate._vars.status.get() + assert g.UNLOCKING in gate._vars.status.get() def test_poll_without_a_fetch_is_inert(self, gate: MealGate) -> None: """A stray poll after completion must stop, not block on an empty queue.""" diff --git a/diet_guard/tests/test_gatelock_fanout.py b/diet_guard/tests/test_gatelock_fanout.py index a16ab79..535dc93 100644 --- a/diet_guard/tests/test_gatelock_fanout.py +++ b/diet_guard/tests/test_gatelock_fanout.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock import pytest +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock import MealGate from diet_guard.tests._gate_fixtures import fake_tk from diet_guard.tests.conftest import TWO_OUTPUTS @@ -100,7 +101,7 @@ class TestGateFansOut: for surface in dual_gate._cal_surfaces: assert surface.budget_entry.configured.get("state") == "normal" - assert surface.budget_edit_button.configured.get("text") == "Save" + assert surface.budget_edit_button.configured.get("text") == g.SAVE def test_frame_group_covers_every_monitor(self, dual_gate: MealGate) -> None: """The outer frame is exposed per surface, for tab selection.""" diff --git a/diet_guard/tests/test_gatelock_fillall.py b/diet_guard/tests/test_gatelock_fillall.py index f316e3d..ad0dc16 100644 --- a/diet_guard/tests/test_gatelock_fillall.py +++ b/diet_guard/tests/test_gatelock_fillall.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, patch from diet_guard import _gatelock_fillall +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_fetch import FETCH_POLL_MS from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks from diet_guard._gatelock_kuchnia import DeliveryResult @@ -115,7 +116,7 @@ class TestFirstClick: with fetching() as start: h.flow.click() start.assert_not_called() - assert "only available on the real lock" in h.status + assert h.status == f"{g.DEMO_ONLY} {g.FILL_ALL}" assert h.label.get() == FILL_LABEL def test_a_click_while_fetching_is_ignored(self) -> None: @@ -127,7 +128,7 @@ class TestFirstClick: start.assert_called_once_with( _gatelock_fillall._refresh_delivery, now_local().date() ) - assert h.statuses == [("Loading today's delivery…", False)] + assert h.statuses == [(f"{g.BUSY} {g.DELIVERY}", False)] h.root.after.assert_called_once_with(FETCH_POLL_MS, h.flow._poll) def test_the_poll_rearms_while_the_worker_runs(self) -> None: @@ -149,7 +150,7 @@ class TestFirstClick: h = Harness() with fetching(DeliveryResult(dishes=(), reason="panel down")): propose(h) - assert h.statuses[-1] == ("panel down — still locked.", True) + assert h.statuses[-1] == (f"{g.ERROR} {g.DELIVERY}", True) assert h.label.get() == FILL_LABEL # Fetch done: the next click may fetch again rather than being ignored. assert h.flow._result is None @@ -158,7 +159,7 @@ class TestFirstClick: h = Harness() with fetching(delivered()): propose(h) - assert h.status == "No catering delivery today." + assert h.status == f"{g.EMPTY} {g.DELIVERY}" assert h.label.get() == FILL_LABEL def test_every_slot_already_logged(self) -> None: @@ -167,7 +168,7 @@ class TestFirstClick: h = Harness() with fetching(delivered(*FOUR)): propose(h) - assert h.status == "Every slot already has a meal — nothing to fill." + assert h.status == f"{g.OK} {g.CLOCK}" assert h.label.get() == FILL_LABEL def test_the_proposal_writes_nothing(self) -> None: @@ -175,10 +176,9 @@ class TestFirstClick: h = Harness() with fetching(delivered(*FOUR)): propose(h) - assert h.label.get() == "✓ Confirm (3)" + assert h.label.get() == f"{g.OK} 3" assert h.status == ( - "Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, " - "20:00 Kolacja — click Confirm." + f"{g.DELIVERY} 3 1144 kcal 08:00 Owsianka 16:00 Obiad 20:00 Kolacja" ) assert logged() == {720: ["my sandwich"]} h.on_logged.assert_not_called() @@ -198,7 +198,7 @@ class TestConfirm: 960: ["Obiad"], 1200: ["Kolacja"], } - assert h.status == "Logged 3 catering dish(es)." + assert h.status == f"{g.OK} {g.DELIVERY} 3" h.on_logged.assert_called_once_with() assert h.label.get() == FILL_LABEL @@ -210,17 +210,17 @@ class TestConfirm: propose(h) h.flow.click() assert logged()[480] == ["Owsianka", "Wrap"] - assert h.status == "Logged 5 catering dish(es)." + assert h.status == f"{g.OK} {g.DELIVERY} 5" def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None: h = Harness() with fetching(delivered(*FOUR)): propose(h) - assert h.label.get() == "✓ Confirm (4)" + assert h.label.get() == f"{g.OK} 4" log_meal("toast", _nutrition(), 480) h.flow.click() assert logged()[480] == ["toast"] - assert h.status == "Logged 3 catering dish(es)." + assert h.status == f"{g.OK} {g.DELIVERY} 3" def test_every_slot_taken_between_the_clicks(self) -> None: h = Harness() @@ -229,7 +229,7 @@ class TestConfirm: for slot in (480, 720, 960, 1200): log_meal(f"mine {slot}", _nutrition(), slot) h.flow.click() - assert h.status == "Every slot already has a meal — nothing to fill." + assert h.status == f"{g.OK} {g.CLOCK}" h.on_logged.assert_not_called() assert h.label.get() == FILL_LABEL assert all(descs == [f"mine {s}"] for s, descs in logged().items()) @@ -247,4 +247,4 @@ class TestConfirm: assert logged() == {} h.on_logged.assert_not_called() assert h.label.get() == FILL_LABEL - assert h.status == "Loading today's delivery…" + assert h.status == f"{g.BUSY} {g.DELIVERY}" diff --git a/diet_guard/tests/test_gatelock_fillall_gate.py b/diet_guard/tests/test_gatelock_fillall_gate.py index 9976fc5..f179a41 100644 --- a/diet_guard/tests/test_gatelock_fillall_gate.py +++ b/diet_guard/tests/test_gatelock_fillall_gate.py @@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch import pytest from diet_guard import _gatelock_delivery, _gatelock_fillall, _gatelock_layout +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock import MealGate from diet_guard._gatelock_fillall import FILL_LABEL, _refresh_delivery from diet_guard._gatelock_layout import build_layout @@ -82,7 +83,7 @@ class TestTheGateDrivesTheFlow: with patch.object(_gatelock_fillall, "start_delivery_fetch") as start: gate._on_fill_all() start.assert_not_called() - assert "only available on the real lock" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.DEMO_ONLY} {g.FILL_ALL}" def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None: gate.demo_mode = False # read once, when the flow is first built @@ -94,7 +95,7 @@ class TestTheGateDrivesTheFlow: with patch.object(_gatelock_delivery, "due_slots", side_effect=due): _fill(gate, delivered(*FOUR)) status = gate._vars.status.get() - assert status == "Filled from catering — all meals logged, unlocking…" + assert status == f"{g.OK} {g.DELIVERY} {g.UNLOCKING}" assert gate._pending == [] gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close) assert sorted(logged()) == [480, 720, 960, 1200] @@ -109,8 +110,8 @@ class TestTheGateDrivesTheFlow: with patch.object(_gatelock_delivery, "due_slots", side_effect=due): _fill(gate, delivered(*two)) assert gate._pending == [720] - assert "Pulled 1 meal " in gate._vars.status.get() - assert "unlocking" not in gate._vars.status.get() + assert g.UNLOCKING not in gate._vars.status.get() + assert gate._vars.status.get().startswith(g.OK) class TestLeftoverDishesAtUnlock: @@ -120,22 +121,21 @@ class TestLeftoverDishesAtUnlock: # Case and padding differ from the dish name on purpose. log_meal(" KOLACJA ", _nutrition(), 20) gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3)) - gate._unlock("Logged 20:00") + gate._unlock(f"{g.OK} 20:00") assert gate._vars.status.get() == ( - "Logged 20:00 (1 more dish delivered; log with 'ate') — " - "all meals logged, unlocking…" + f"{g.OK} 20:00 {g.DELIVERY} +1 {g.UNLOCKING}" ) def test_unlogged_dishes_are_all_counted(self, gate: MealGate) -> None: gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3)) - gate._unlock("Logged 20:00") - assert "(2 more dishes delivered;" in gate._vars.status.get() + gate._unlock(f"{g.OK} 20:00") + assert f"{g.DELIVERY} +2" in gate._vars.status.get() def test_every_queued_dish_logged_means_no_suffix(self, gate: MealGate) -> None: log_meal("Kolacja", _nutrition(), 20) gate._delivery_pending = (dish("Kolacja", 4),) - gate._unlock("Logged 20:00") - assert "more dish" not in gate._vars.status.get() + gate._unlock(f"{g.OK} 20:00") + assert g.DELIVERY not in gate._vars.status.get() class TestTheButton: @@ -188,8 +188,8 @@ class TestTheButton: widgets = build_layout(notebook, vars_, callbacks, demo_mode=False) button = _find_fill_button(widgets.frame, str(vars_.fill_label)) assert button.cget("text") == FILL_LABEL - vars_.fill_label.set("✓ Confirm (3)") - assert button.cget("text") == "✓ Confirm (3)" + vars_.fill_label.set(f"{g.OK} 3") + assert button.cget("text") == f"{g.OK} 3" button.invoke() on_fill_all.assert_called_once_with() finally: diff --git a/diet_guard/tests/test_gatelock_form.py b/diet_guard/tests/test_gatelock_form.py index dbe4e7e..e6e910d 100644 --- a/diet_guard/tests/test_gatelock_form.py +++ b/diet_guard/tests/test_gatelock_form.py @@ -13,6 +13,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import patch +from diet_guard import _gatelock_glyphs as g from diet_guard import ( _gatelock_ui, ) @@ -107,8 +108,8 @@ class TestBasisAndAmount: write_budget(2000) gate._refresh_projection() text = gate._vars.projection.get() - assert "left" in text - assert "after this item" not in text + assert f"{g.BUDGET} 2000" in text + assert "→" not in text def test_keyrelease_grams_mode(self, gate: MealGate) -> None: """In grams mode the per-item weight is not touched on keyrelease.""" diff --git a/diet_guard/tests/test_gatelock_mealflow.py b/diet_guard/tests/test_gatelock_mealflow.py index 76713e6..cb21fa9 100644 --- a/diet_guard/tests/test_gatelock_mealflow.py +++ b/diet_guard/tests/test_gatelock_mealflow.py @@ -12,6 +12,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import patch +from diet_guard import _gatelock_glyphs as g from diet_guard import _gatelock_mealflow from diet_guard.tests.conftest import _nutrition @@ -116,14 +117,14 @@ class TestSubmit: def test_empty_description(self, gate: MealGate) -> None: """Submitting with no description prompts for one.""" gate._on_submit() - assert "Type what you ate" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.ERROR} {g.FOOD}" def test_non_numeric_macros(self, gate: MealGate) -> None: """Non-numeric macros are rejected before logging.""" gate._set_desc("apple") gate._widgets.macros.kcal.insert(0, "abc") gate._on_submit() - assert "must be numbers" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.ERROR} {g.PROTEIN}{g.CARBS}{g.FAT}" def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None: """A blank calorie field looks the food up rather than logging.""" @@ -138,7 +139,7 @@ class TestSubmit: gate._widgets.macros.kcal.insert(0, "200") with patch.object(gate, "_current_nutrition", return_value=None): gate._on_submit() - assert "Enter the calories" in gate._vars.status.get() + assert gate._vars.status.get() == f"{g.ERROR} kcal" def test_valid_submit_records(self, gate: MealGate) -> None: """A described, priced meal is recorded.""" @@ -163,7 +164,7 @@ class TestLookup: gate._set_desc("nonsense") with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]): gate._begin_lookup("nonsense") - assert "Couldn't look that up" in gate._vars.status.get() + assert gate._vars.status.get().startswith(g.ERROR) def test_single_candidate(self, gate: MealGate) -> None: """A single match fills the fields and invites review.""" @@ -173,7 +174,8 @@ class TestLookup: return_value=[("apple", _nutrition(95, 100))], ): gate._begin_lookup("apple") - assert "Review the values" in gate._vars.status.get() + assert gate._vars.status.get().startswith(g.OK) + assert g.TIMES not in gate._vars.status.get() def test_multiple_candidates(self, gate: MealGate) -> None: """Several matches invite picking another.""" @@ -186,7 +188,7 @@ class TestLookup: ], ): gate._begin_lookup("apple") - assert "pick another" in gate._vars.status.get() + assert gate._vars.status.get().endswith(f"{g.TIMES}2") class TestRecord: @@ -219,9 +221,9 @@ class TestRecord: ): gate._record("apple", _nutrition(95, 100)) assert gate._pending == [720] - assert "next meal" in gate._vars.status.get() + assert gate._vars.status.get().startswith(g.OK) def test_unlock_schedules_close(self, gate: MealGate) -> None: """Unlock sets the closing status and schedules teardown.""" gate._unlock("logged X") - assert "unlocking" in gate._vars.status.get() + assert g.UNLOCKING in gate._vars.status.get() diff --git a/diet_guard/tests/test_gatelock_scheduleedit.py b/diet_guard/tests/test_gatelock_scheduleedit.py index 8e8914b..710b318 100644 --- a/diet_guard/tests/test_gatelock_scheduleedit.py +++ b/diet_guard/tests/test_gatelock_scheduleedit.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING import pytest +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule from diet_guard._meal_schedule_store import current_schedule, record_schedule_change @@ -64,7 +65,7 @@ class TestEditToggle: assert not gate._cal_editing_schedule assert current_schedule() == MealSchedule(435, 1140, 5) - assert _status(gate) == "Saved." + assert _status(gate) == g.OK assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15") def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None: @@ -106,22 +107,22 @@ class TestValidation: _type(gate, first, "20:00", "4") assert not gate._save_schedule_entry() - assert "HH:MM" in _status(gate) + assert _status(gate) == f"{g.ERROR} {g.CLOCK}" def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None: _type(gate, "08:00", "20:00", "five") assert not gate._save_schedule_entry() - assert "meals per day as a number" in _status(gate) + assert _status(gate) == f"{g.ERROR} {g.TIMES}" @pytest.mark.parametrize( ("first", "last", "count", "reason"), [ - ("20:00", "08:00", "4", "at least 15 min after"), - ("08:00", "08:14", "2", "at least 15 min after"), - ("08:00", "20:00", "1", "Meals per day must be 2-6."), - ("08:00", "20:00", "9", "Meals per day must be 2-6."), - ("08:00", "08:45", "5", "Too many meals"), + ("20:00", "08:00", "4", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"), + ("08:00", "08:14", "2", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"), + ("08:00", "20:00", "1", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"), + ("08:00", "20:00", "9", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"), + ("08:00", "08:45", "5", f"{g.ERROR} {g.TIMES} ≤ 4"), ], ) def test_rejects_what_normalize_would_silently_change( @@ -130,7 +131,7 @@ class TestValidation: _type(gate, first, last, count) assert not gate._save_schedule_entry() - assert reason in _status(gate) + assert reason == _status(gate) assert current_schedule() == DEFAULT_SCHEDULE def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None: diff --git a/diet_guard/tests/test_kuchnia_autoload.py b/diet_guard/tests/test_kuchnia_autoload.py index 43301c6..8dec839 100644 --- a/diet_guard/tests/test_kuchnia_autoload.py +++ b/diet_guard/tests/test_kuchnia_autoload.py @@ -14,6 +14,7 @@ import queue from unittest.mock import patch from diet_guard import _gatelock_delivery +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate @@ -65,4 +66,4 @@ class TestAutoload: def test_the_button_still_reports_both(self) -> None: gate = _Gate() _deliver(gate, DeliveryResult(dishes=(), reason="panel down"), asked=True) - assert "panel down" in gate.last_status + assert gate.last_status == f"{g.ERROR} {g.DELIVERY}" diff --git a/diet_guard/tests/test_kuchnia_gate.py b/diet_guard/tests/test_kuchnia_gate.py index 3ba2263..f0e0b84 100644 --- a/diet_guard/tests/test_kuchnia_gate.py +++ b/diet_guard/tests/test_kuchnia_gate.py @@ -24,6 +24,7 @@ from unittest.mock import MagicMock, patch import pytest from diet_guard import _gatelock_delivery +from diet_guard import _gatelock_glyphs as g from diet_guard._gatelock_delivery import _PullFlows from diet_guard._gatelock_kuchnia import ( UNEXPECTED, @@ -131,7 +132,7 @@ class TestLoadButton: gate = _Gate() gate.demo_mode = True gate._on_load_delivery() - assert "only available on the real lock" in gate.last_status + assert gate.last_status == f"{g.DEMO_ONLY} {g.DELIVERY}" assert gate._delivery_result is None def test_a_second_click_while_in_flight_is_ignored(self) -> None: @@ -146,15 +147,14 @@ class TestLoadButton: gate = _Gate() _deliver(gate, DeliveryResult(dishes=(), reason="panel down")) text, error = gate.statuses[-1] - assert "panel down" in text - assert "still locked" in text + assert text == f"{g.ERROR} {g.DELIVERY}" assert error is True assert gate._descs == [] - def test_no_delivery_today_says_so(self) -> None: + def test_no_delivery_today_shows_the_empty_mark(self) -> None: gate = _Gate() _deliver(gate, DeliveryResult(dishes=(), reason=None)) - assert "No catering delivery today" in gate.last_status + assert gate.last_status == f"{g.EMPTY} {g.DELIVERY}" def test_a_dish_is_offered_but_never_logged(self) -> None: gate = _Gate() @@ -165,21 +165,21 @@ class TestLoadButton: assert gate._references == [dish_nutrition(_dish())] assert gate._references[0].grams == 318.0 # ...and that is all. Submitting stays the user's explicit action. - assert "Log & Continue" in gate.last_status + assert gate.last_status == f"{g.DELIVERY} Kaszotto" def test_dishes_are_offered_one_at_a_time_in_meal_order(self) -> None: gate = _Gate() dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1)) _deliver(gate, DeliveryResult(dishes=dishes, reason=None)) assert gate._descs == ["Sniadanie"] - assert "1 more to go" in gate.last_status + assert gate.last_status.endswith("+1") gate._prefill_next_dish() assert gate._descs == ["Sniadanie", "Kolacja"] - def test_the_last_dish_has_no_more_to_go_suffix(self) -> None: + def test_the_last_dish_has_no_count_suffix(self) -> None: gate = _Gate() _deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None)) - assert "more to go" not in gate.last_status + assert "+" not in gate.last_status def test_prefilling_past_the_end_is_a_no_op(self) -> None: gate = _Gate() diff --git a/diet_guard/tests/test_kuchnia_queue.py b/diet_guard/tests/test_kuchnia_queue.py index 8cb69e4..7275f0c 100644 --- a/diet_guard/tests/test_kuchnia_queue.py +++ b/diet_guard/tests/test_kuchnia_queue.py @@ -22,6 +22,7 @@ import queue from typing import TYPE_CHECKING from unittest.mock import patch +from diet_guard import _gatelock_glyphs as g from diet_guard import _gatelock_mealflow from diet_guard._gatelock_kuchnia import DeliveryResult from diet_guard._kuchnia_log import dish_nutrition @@ -60,16 +61,17 @@ class TestTheQueueSurvivesASubmit: reason=None, ), ) - gate._prefill_next_dish("Logged 08:00: 391 kcal") - assert "Logged 08:00: 391 kcal" in gate.last_status - assert "Loaded: Second" in gate.last_status + logged = f"{g.OK} 08:00 391 kcal" + gate._prefill_next_dish(logged) + assert logged in gate.last_status + assert f"{g.DELIVERY} Second" in gate.last_status def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None: # The button and the autoload pass no ``logged``, so the status must # not grow a stray leading separator. gate = _Gate() _deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None)) - assert gate.last_status.startswith("Loaded: Only") + assert gate.last_status.startswith(f"{g.DELIVERY} Only") def test_an_exhausted_queue_prefills_nothing(self) -> None: # The guard that lets ``_finish_slot`` call this unconditionally. @@ -143,8 +145,8 @@ class TestTheGateAdvancesTheQueue: ): gate._record("apple", _nutrition(95, 100)) assert not gate._delivery_pending - assert "Loaded: Second course" in gate._vars.status.get() - assert "Logged 08:00" in gate._vars.status.get() + assert f"{g.DELIVERY} Second course" in gate._vars.status.get() + assert f"{g.OK} 08:00" in gate._vars.status.get() def test_a_prefilled_dish_logs_exactly_its_own_values(self, gate: MealGate) -> None: """What the submit logs is the dish, not the dish scaled by grams/100. @@ -176,5 +178,5 @@ class TestTheGateAdvancesTheQueue: was never offered. """ gate._delivery_pending = (_dish("Kolacja"),) - gate._unlock("Logged 20:00: 391 kcal") - assert "1 more dish delivered" in gate._vars.status.get() + gate._unlock(f"{g.OK} 20:00 391 kcal") + assert f"{g.DELIVERY} +1" in gate._vars.status.get() diff --git a/diet_guard/tests/test_textgate_scan_clean.py b/diet_guard/tests/test_textgate_scan_clean.py new file mode 100644 index 0000000..398f809 --- /dev/null +++ b/diet_guard/tests/test_textgate_scan_clean.py @@ -0,0 +1,70 @@ +"""Guard: ``textgate scan`` lists nothing that ``docs/DOCS-textgate-keep.md`` omits. + +The per-surface guards (fake tk, widget tree, wrapper) catch what a scenario +drives; this one catches what the line-based scanner sees anywhere in the repo, +so the count cannot drift back. Every hit must be documented as a kept row in +the keep doc (proper nouns, Claude-facing MCP titles, doubts) or live under the +exempt ``docs/explanations/`` pages. The scanner is a per-machine tool, so the +test skips where it is absent (CI); the same rows are what kuhy reviews. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +KEEP = ROOT / "docs" / "DOCS-textgate-keep.md" +EXEMPT_PREFIX = "docs/explanations/" +HIT = re.compile(r"^hardcoded\s+(\S+?):(\d+)\s+(.*)$") + + +def parse_hits(output: str) -> list[tuple[str, str]]: + """``(path, quoted text)`` for each scan line.""" + hits = [] + for line in output.splitlines(): + match = HIT.match(line) + if match: + hits.append((match.group(1), match.group(3))) + return hits + + +def undocumented(hits: list[tuple[str, str]], keep: str) -> list[str]: + """Hits outside the exempt docs whose ``path`` and text the keep doc omits.""" + return [ + f"{path} {text}" + for path, text in hits + if not path.startswith(EXEMPT_PREFIX) and f"`{path}`" not in keep + ] + + +def test_parse_and_filter_are_strict() -> None: + """Canary: a new file's hit is reported, a documented or exempt one is not.""" + out = ( + 'hardcoded app/lib/new.dart:3 "Hello"\n' + 'hardcoded docs/explanations/a.html:9 "Page"\n' + 'hardcoded app/lib/main.dart:65 "Diet Guard"\n' + "240 to rework\n" + ) + hits = parse_hits(out) + assert len(hits) == 3 + assert undocumented(hits, "`app/lib/main.dart`") == ['app/lib/new.dart "Hello"'] + + +@pytest.mark.skipif(shutil.which("textgate") is None, reason="textgate not installed") +def test_scan_lists_only_documented_rows() -> None: + result = subprocess.run( + [shutil.which("textgate") or "textgate", "scan"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + timeout=50, + ) + hits = parse_hits(result.stdout) + assert hits, "the scan returned nothing: the parser or the scanner broke" + assert not undocumented(hits, KEEP.read_text(encoding="utf-8")) diff --git a/docs/DOCS-textgate-keep.md b/docs/DOCS-textgate-keep.md new file mode 100644 index 0000000..a405c95 --- /dev/null +++ b/docs/DOCS-textgate-keep.md @@ -0,0 +1,56 @@ +# Text kept on purpose (textgate keep list) + +kuhy's rule: nothing he sees carries text unless he asked. `textgate scan` +still lists the rows below; each is documented here, and +`diet_guard/tests/test_textgate_scan_clean.py` fails on any hit this file +does not name by path. The per-surface guards are `test_gate_no_words.py` +and `test_gate_no_words_static.py` (Tk gate), `app/test/no_words_*_test.dart` +(phone and web screens) and `app/test/desktop/desktop_wrapper_no_text_test.dart` +(pages served into the Chrome window). + +## Exempt + +- `docs/explanations/*.html`: documentation pages, read in a browser on + purpose. Not app UI. The scan lists them; the guard ignores that prefix. + +## Proper nouns (OS-level app name) + +| Where | Text | Why | +|---|---|---| +| `app/lib/main.dart` | `Diet Guard` | `MaterialApp.title`: task switcher and tab name | +| `diet_guard/_gatelock.py` | `Diet Gate` | window title, an identifier under override-redirect | + +The launcher label and notification channel (`DietGuard`) live in +`AndroidManifest.xml` and `notification_backend_io.dart`; a due-slot +notification shows only the slot clock time and has no body. + +## Spoken only (never painted) + +| Where | Text | Evidence | +|---|---|---| +| `app/lib/screens/log_meal_fill_mixin.dart` | fill failed / cancelled sentences | handed to `StatusCue` as the semantic label; the cue paints a glyph and a count | +| `app/lib/services/kuchnia_fill_flow.dart` | delivery and logged sentences | `FillOutcome.message`, same route; `no_words_kuchnia_test.dart` proves the snackbar paints a glyph and a number | + +## Read by Claude, not by kuhy + +MCP tool titles in `diet_guard/_mcp.py` and `diet_guard/_mcp_read.py`, and the +commit text in `diet_guard/_prune_peers.py`: tool metadata and a CLI commit +message. + +## Terminal output (settled: keeps its text) + +All `print`/stdout/stderr and `--help` of the Python CLI, `app/bin/` and the +stderr lines of `app/lib/desktop/wrapper_server.dart`. Pages served into the +Chrome window answer errors with a status code and an empty body. + +## DOUBT rows (kuhy to confirm or file a task) + +- `MET` next to kcal figures (activity rows), and unit symbols `kcal kg lb st cm km min`: bare identifiers beside a number. +- Formula proper names painted outside the activity sheet: `app/lib/widgets/body/body_stats_cards.dart`, `goal_card.dart` (BMI Prime, Trefethen, ideal-weight and resting-burn formula names). The Tk gate dropped them. +- Workout names (`session.label`, `session.kind`) are PC-published content. + +## Framework pickers (not reworked; follow-up task filed) + +`showDatePicker`, `showDateRangePicker`, `showTimePicker` paint Select date, +Cancel, OK, Save: `log_meal_future_mixin.dart`, `history_filter_sheet.dart`, +`dated_value_card.dart`, `meal_time_dropdown.dart`, `profile_card.dart`.