mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45627f7075
commit
43e4b4070d
@ -2,7 +2,7 @@ name: diet_guard_app
|
|||||||
description: "Companion phone app for diet_guard: log meals on the go."
|
description: "Companion phone app for diet_guard: log meals on the go."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.146+1
|
version: 1.0.147+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import LockConfig
|
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._averages_report import monthly_average, weekly_average
|
||||||
from diet_guard._daystatus import (
|
from diet_guard._daystatus import (
|
||||||
DayStatus,
|
DayStatus,
|
||||||
@ -28,6 +28,7 @@ from diet_guard._gatelock_ui import BG, FG
|
|||||||
from diet_guard._state import now_local
|
from diet_guard._state import now_local
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from diet_guard._averages import PeriodAverage
|
||||||
from diet_guard._budget_history import BudgetSchedule
|
from diet_guard._budget_history import BudgetSchedule
|
||||||
from diet_guard._state import DayLog
|
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
|
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(
|
def streaks_text(
|
||||||
status_map_: dict[str, DayStatus],
|
status_map_: dict[str, DayStatus],
|
||||||
*,
|
*,
|
||||||
@ -145,10 +141,8 @@ def streaks_text(
|
|||||||
"""
|
"""
|
||||||
logging = logging_streak(status_map_, today=today)
|
logging = logging_streak(status_map_, today=today)
|
||||||
adherence = adherence_streak(status_map_, today=today)
|
adherence = adherence_streak(status_map_, today=today)
|
||||||
return (
|
# Logged-days streak under the pencil, within-budget streak under the check.
|
||||||
f"Logging streak: {logging} {_plural(logging)} · "
|
return f"{g.FOOD} {logging} {g.OK} {adherence}"
|
||||||
f"Adherence streak: {adherence} {_plural(adherence)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ytd_text(status_map_: dict[str, DayStatus], *, today: date | None = None) -> str:
|
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)
|
tally = year_to_date_tally(status_map_, today=today)
|
||||||
return (
|
return (
|
||||||
f"This year: logged {tally.logged_days}/{tally.elapsed_days} days · "
|
f"{g.YEAR_TOTAL} {g.FOOD} {tally.logged_days}/{tally.elapsed_days} "
|
||||||
f"{tally.adherent_days} within budget"
|
f"{g.OK} {tally.adherent_days}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _average_part(period: PeriodAverage) -> str:
|
def _average_part(period: PeriodAverage) -> str:
|
||||||
"""Render one period as ``"NNNN kcal (band)"``, or ``"no data"``."""
|
"""Render one period as ``"NNNN kcal <band glyph>"``, or a dash."""
|
||||||
if period.avg_kcal is None:
|
if period.avg_kcal is None or period.band is None:
|
||||||
return band_label(period.band)
|
return g.NO_VALUE
|
||||||
return f"{period.avg_kcal:.0f} kcal ({band_label(period.band)})"
|
return f"{period.avg_kcal:.0f} kcal {g.BAND[period.band.value]}"
|
||||||
|
|
||||||
|
|
||||||
def averages_text(
|
def averages_text(
|
||||||
@ -190,6 +184,6 @@ def averages_text(
|
|||||||
week = weekly_average(log, schedule=schedule, today=today)
|
week = weekly_average(log, schedule=schedule, today=today)
|
||||||
month = monthly_average(log, schedule=schedule, today=today)
|
month = monthly_average(log, schedule=schedule, today=today)
|
||||||
return (
|
return (
|
||||||
f"Avg/day (to yesterday): week {_average_part(week)} \u00b7 "
|
f"{g.AVERAGE} {g.SPAN['week']} {_average_part(week)} "
|
||||||
f"month {_average_part(month)}"
|
f"{g.AVERAGE} {g.SPAN['month']} {_average_part(month)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -131,7 +131,7 @@ class MealGate(_GateCalendar):
|
|||||||
self._state = _GateState()
|
self._state = _GateState()
|
||||||
self.root = GateRoot()
|
self.root = GateRoot()
|
||||||
self.root.on_callback_error = self.on_callback_error
|
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
|
# No bg=/fg=/... overrides here: LockConfig's own defaults already
|
||||||
# are the unified-design-system palette, and _gatelock_ui reads the
|
# are the unified-design-system palette, and _gatelock_ui reads the
|
||||||
# same LockConfig() rather than a separate hardcoded copy -- see
|
# same LockConfig() rather than a separate hardcoded copy -- see
|
||||||
|
|||||||
@ -18,18 +18,9 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import ScrollableSurface
|
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_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_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 (
|
from diet_guard._body_store import (
|
||||||
MAX_KG,
|
MAX_KG,
|
||||||
MIN_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._body_view import BodyView, budget_or_default, build_view
|
||||||
from diet_guard._daystatus import day_total_kcal
|
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_graph import GraphBox, draw_weight_graph, in_range
|
||||||
from diet_guard._gatelock_body_input import (
|
from diet_guard._gatelock_body_input import (
|
||||||
HEIGHT_CM,
|
HEIGHT_CM,
|
||||||
@ -63,7 +64,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
__all__ = ["BodyTab"]
|
__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:
|
class BodyTab:
|
||||||
@ -119,17 +122,16 @@ class BodyTab:
|
|||||||
view = build_view(today, self._goal)
|
view = build_view(today, self._goal)
|
||||||
target = None if view.numbers is None else view.numbers.target
|
target = None if view.numbers is None else view.numbers.target
|
||||||
kcal = None if target is None else target.kcal
|
kcal = None if target is None else target.kcal
|
||||||
self.vars.goal.answer.set("" if kcal is None else f"eat {kcal} kcal")
|
self.vars.goal.answer.set("" if kcal is None else f"→ {kcal} kcal")
|
||||||
lines = target_lines(view)
|
|
||||||
texts = {
|
texts = {
|
||||||
"summary": profile_lines(view, today),
|
"summary": profile_glyphs(view, today),
|
||||||
"table": lines[1:] if kcal is not None else lines,
|
"table": target_glyphs(view),
|
||||||
"bodyfat": bodyfat_lines(view),
|
"bodyfat": bodyfat_glyphs(view),
|
||||||
"bmi": bmi_lines(view),
|
"bmi": bmi_glyphs(view),
|
||||||
"ideal": ideal_lines(view),
|
"ideal": ideal_glyphs(view),
|
||||||
"bmr": bmr_lines(view),
|
"bmr": bmr_glyphs(view),
|
||||||
"workouts": activity_lines(view),
|
"workouts": activity_glyphs(view),
|
||||||
"plan": plan_lines(self._plan(today)),
|
"plan": plan_glyphs(self._plan(today)),
|
||||||
}
|
}
|
||||||
for name, lines in texts.items():
|
for name, lines in texts.items():
|
||||||
self.vars.blocks[name].set("\n".join(lines))
|
self.vars.blocks[name].set("\n".join(lines))
|
||||||
@ -166,8 +168,9 @@ class BodyTab:
|
|||||||
|
|
||||||
def _saved(self, message: str) -> None:
|
def _saved(self, message: str) -> None:
|
||||||
self.vars.status.set(message)
|
self.vars.status.set(message)
|
||||||
|
# Saved here, but the publish failed: the reason goes to the log only.
|
||||||
publish_after_log_detached(
|
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()
|
self.refresh()
|
||||||
|
|
||||||
@ -176,7 +179,7 @@ class BodyTab:
|
|||||||
self._goal = goal_choice(self.vars.goal, self._goal)
|
self._goal = goal_choice(self.vars.goal, self._goal)
|
||||||
load_goal(self.vars.goal, self._goal)
|
load_goal(self.vars.goal, self._goal)
|
||||||
set_goal(self._goal)
|
set_goal(self._goal)
|
||||||
self._saved("Goal saved.")
|
self._saved(g.OK)
|
||||||
|
|
||||||
def save_profile(self) -> None:
|
def save_profile(self) -> None:
|
||||||
"""Validate and store birth date, height and sex."""
|
"""Validate and store birth date, height and sex."""
|
||||||
@ -189,20 +192,22 @@ class BodyTab:
|
|||||||
or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1]
|
or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1]
|
||||||
or sex not in {"m", "f"}
|
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
|
return
|
||||||
set_profile(birth=birth, height_cm=height, sex=sex)
|
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:
|
def save_weight(self) -> None:
|
||||||
"""Validate and store the typed weigh-in."""
|
"""Validate and store the typed weigh-in."""
|
||||||
kg = parse_float(self.vars.logs.weight.get())
|
kg = parse_float(self.vars.logs.weight.get())
|
||||||
day = parse_day(self.vars.logs.weight_day.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:
|
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
|
return
|
||||||
self._store_weight(day, kg)
|
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:
|
def delete_weight(self) -> None:
|
||||||
"""Delete the weigh-in for the typed day."""
|
"""Delete the weigh-in for the typed day."""
|
||||||
@ -211,7 +216,7 @@ class BodyTab:
|
|||||||
self.vars.status.set(_DAY_HINT)
|
self.vars.status.set(_DAY_HINT)
|
||||||
return
|
return
|
||||||
self._store_weight(day, None)
|
self._store_weight(day, None)
|
||||||
self._saved(f"Deleted the weigh-in for {day}.")
|
self._saved(f"{g.DELETE} {day}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _store_weight(day: str, kg: float | None) -> None:
|
def _store_weight(day: str, kg: float | None) -> None:
|
||||||
@ -225,11 +230,11 @@ class BodyTab:
|
|||||||
pct = parse_float(self.vars.logs.body_fat.get())
|
pct = parse_float(self.vars.logs.body_fat.get())
|
||||||
day = parse_day(self.vars.logs.fat_day.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:
|
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}%"
|
limits = f"{MIN_BODY_FAT:g}{g.DASH}{MAX_BODY_FAT:g}%"
|
||||||
self.vars.status.set(f"Body fat needs {limits} and a day.")
|
self.vars.status.set(f"{g.ERROR} {limits} {_DAY_SHAPE}")
|
||||||
return
|
return
|
||||||
set_body_fat(day, pct)
|
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:
|
def delete_body_fat(self) -> None:
|
||||||
"""Delete the body-fat reading for the typed day."""
|
"""Delete the body-fat reading for the typed day."""
|
||||||
@ -238,4 +243,4 @@ class BodyTab:
|
|||||||
self.vars.status.set(_DAY_HINT)
|
self.vars.status.set(_DAY_HINT)
|
||||||
return
|
return
|
||||||
set_body_fat(day, None)
|
set_body_fat(day, None)
|
||||||
self._saved(f"Deleted the body-fat reading for {day}.")
|
self._saved(f"{g.DELETE} {day}")
|
||||||
|
|||||||
185
diet_guard/_gatelock_body_glyphs.py
Normal file
185
diet_guard/_gatelock_body_glyphs.py
Normal file
@ -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
|
||||||
@ -18,6 +18,7 @@ from datetime import date, timedelta
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED
|
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_typography import CAPTION
|
||||||
from diet_guard._gatelock_ui import BG, FG
|
from diet_guard._gatelock_ui import BG, FG
|
||||||
|
|
||||||
@ -34,15 +35,17 @@ __all__ = [
|
|||||||
|
|
||||||
# (label, days back; None = everything).
|
# (label, days back; None = everything).
|
||||||
GRAPH_RANGES: tuple[tuple[str, int | None], ...] = (
|
GRAPH_RANGES: tuple[tuple[str, int | None], ...] = (
|
||||||
("30 days", 30),
|
("30", 30),
|
||||||
("90 days", 90),
|
("90", 90),
|
||||||
("1 year", 365),
|
("365", 365),
|
||||||
("All", None),
|
(INFINITE, None),
|
||||||
)
|
)
|
||||||
_HEALTHY_FILL = "#1f3a2a"
|
_HEALTHY_FILL = "#1f3a2a"
|
||||||
_GRID = "#33363d"
|
_GRID = "#33363d"
|
||||||
_DOT_R = 3
|
_DOT_R = 3
|
||||||
_TICKS = 4
|
_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).
|
# Keep a flat line off the frame: pad the value range by this much (kg).
|
||||||
_MIN_SPAN_KG = 2.0
|
_MIN_SPAN_KG = 2.0
|
||||||
# Include a healthy-band edge on the axis only when it is this close (kg).
|
# 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=""
|
box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline=""
|
||||||
)
|
)
|
||||||
font = (_COLORS.typography.font_family, CAPTION)
|
font = (_COLORS.typography.font_family, CAPTION)
|
||||||
|
# The green band is the healthy-BMI weight range; a check marks it.
|
||||||
canvas.create_text(
|
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,
|
box: GraphBox,
|
||||||
healthy: tuple[float, float] | None,
|
healthy: tuple[float, float] | None,
|
||||||
) -> 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")
|
canvas.delete("all")
|
||||||
font = (_COLORS.typography.font_family, CAPTION)
|
font = (_COLORS.typography.font_family, CAPTION)
|
||||||
if not weights:
|
if not weights:
|
||||||
canvas.create_text(
|
canvas.create_text(
|
||||||
box.width / 2,
|
box.width / 2,
|
||||||
box.height / 2,
|
box.height / 2,
|
||||||
text="Nothing logged in this range yet.",
|
text=EMPTY,
|
||||||
fill=_MUTED,
|
fill=_MUTED,
|
||||||
font=font,
|
font=(_COLORS.typography.font_family, _EMPTY_SIZE),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
span = value_span(list(weights.values()), healthy)
|
span = value_span(list(weights.values()), healthy)
|
||||||
|
|||||||
@ -9,8 +9,9 @@ from __future__ import annotations
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import TYPE_CHECKING
|
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._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:
|
if TYPE_CHECKING:
|
||||||
from diet_guard._body_energy import Goal
|
from diet_guard._body_energy import Goal
|
||||||
@ -38,25 +39,29 @@ def parse_day(text: str) -> str | None:
|
|||||||
return 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:
|
def load_goal(goal_vars: GoalVars, goal: Goal) -> None:
|
||||||
"""Put ``goal`` into the sentence's pickers."""
|
"""Put ``goal`` into the goal row's pickers, as glyphs (units stay)."""
|
||||||
goal_vars.direction.set(goal.direction)
|
goal_vars.direction.set(DIRECTION.get(goal.direction, goal.direction))
|
||||||
goal_vars.amount.set(f"{goal.amount:g}")
|
goal_vars.amount.set(f"{goal.amount:g}")
|
||||||
goal_vars.weight_unit.set(goal.weight_unit)
|
goal_vars.weight_unit.set(goal.weight_unit)
|
||||||
goal_vars.time_unit.set(goal.time_unit)
|
goal_vars.time_unit.set(SPAN.get(goal.time_unit, goal.time_unit))
|
||||||
goal_vars.activity.set(activity_label(goal.activity))
|
goal_vars.activity.set(ACTIVITY.get(goal.activity, goal.activity))
|
||||||
|
|
||||||
|
|
||||||
def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal:
|
def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal:
|
||||||
"""The goal the pickers show; an unreadable amount keeps ``fallback``'s."""
|
"""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())
|
amount = parse_float(goal_vars.amount.get())
|
||||||
return goal_from_json(
|
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,
|
"amt": fallback.amount if amount is None or amount < 0 else amount,
|
||||||
"wu": goal_vars.weight_unit.get(),
|
"wu": goal_vars.weight_unit.get(),
|
||||||
"tu": goal_vars.time_unit.get(),
|
"tu": _back(SPAN, goal_vars.time_unit.get(), fallback.time_unit),
|
||||||
"act": labels.get(goal_vars.activity.get(), fallback.activity),
|
"act": _back(ACTIVITY, goal_vars.activity.get(), fallback.activity),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -23,21 +23,21 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import ButtonStyle, make_button
|
from gatelock import ButtonStyle, make_button
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._body_energy import (
|
from diet_guard._body_energy import (
|
||||||
ACTIVITY_CHOICES,
|
ACTIVITY_CHOICES,
|
||||||
DIRECTIONS,
|
DIRECTIONS,
|
||||||
PRESET_AMOUNTS,
|
PRESET_AMOUNTS,
|
||||||
TIME_UNITS,
|
TIME_UNITS,
|
||||||
WEIGHT_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_graph import GRAPH_RANGES
|
||||||
from diet_guard._gatelock_body_vars import BodyWidgets
|
from diet_guard._gatelock_body_vars import BodyWidgets
|
||||||
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED
|
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_spacing import MD, SM, XS
|
||||||
from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE
|
from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE
|
||||||
from diet_guard._gatelock_ui import BG, FG
|
from diet_guard._gatelock_ui import BG, FG
|
||||||
from diet_guard._week_plan import WEEKDAYS
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -103,37 +103,35 @@ def _menu(
|
|||||||
|
|
||||||
|
|
||||||
def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
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
|
goal = vars_.goal
|
||||||
row = _row(parent)
|
row = _row(parent)
|
||||||
_label(row, "To", SUBTITLE)
|
_menu(row, goal.direction, [g.DIRECTION[d] for d in DIRECTIONS], cb)
|
||||||
_menu(row, goal.direction, list(DIRECTIONS), cb)
|
|
||||||
_entry(row, goal.amount, 5, SUBTITLE)
|
_entry(row, goal.amount, 5, SUBTITLE)
|
||||||
_menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb)
|
_menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb)
|
||||||
_menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb)
|
_menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb)
|
||||||
_label(row, "/", SUBTITLE)
|
_label(row, "/", SUBTITLE)
|
||||||
_menu(row, goal.time_unit, list(TIME_UNITS), cb)
|
_menu(row, goal.time_unit, [g.SPAN[t] for t in TIME_UNITS], cb)
|
||||||
_label(row, "on", SUBTITLE)
|
_menu(row, goal.activity, [ACTIVITY[k] for k in ACTIVITY_CHOICES], cb)
|
||||||
_menu(row, goal.activity, [activity_label(k) for k in ACTIVITY_CHOICES], cb)
|
|
||||||
tk.Label(
|
tk.Label(
|
||||||
row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT
|
row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT
|
||||||
).pack(side="left", padx=(MD, 0))
|
).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"])
|
_block(parent, vars_.blocks["table"])
|
||||||
|
|
||||||
|
|
||||||
def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
||||||
row = _row(parent)
|
row = _row(parent)
|
||||||
_label(row, "Profile -- born (YYYY-MM-DD)")
|
_label(row, g.BIRTH)
|
||||||
_entry(row, vars_.profile.birth, 11)
|
_entry(row, vars_.profile.birth, 11)
|
||||||
_label(row, "Height cm")
|
_label(row, "cm")
|
||||||
_entry(row, vars_.profile.height, 6)
|
_entry(row, vars_.profile.height, 6)
|
||||||
sex = vars_.profile.sex
|
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(
|
tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack(
|
||||||
side="left"
|
side="left"
|
||||||
)
|
)
|
||||||
_button(row, "Save profile", cb.save_profile)
|
_button(row, g.SAVE, cb.save_profile)
|
||||||
_block(parent, vars_.blocks["summary"])
|
_block(parent, vars_.blocks["summary"])
|
||||||
|
|
||||||
|
|
||||||
@ -146,18 +144,18 @@ def _dated_row(
|
|||||||
row = _row(parent)
|
row = _row(parent)
|
||||||
_label(row, label)
|
_label(row, label)
|
||||||
_entry(row, vars_pair[0], 6)
|
_entry(row, vars_pair[0], 6)
|
||||||
_label(row, "on")
|
_label(row, g.CLOCK)
|
||||||
_entry(row, vars_pair[1], 11)
|
_entry(row, vars_pair[1], 11)
|
||||||
_button(row, "Save", actions[0])
|
_button(row, g.SAVE, actions[0])
|
||||||
_button(row, "Delete that day", actions[1])
|
_button(row, g.DELETE, actions[1])
|
||||||
|
|
||||||
|
|
||||||
def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas:
|
def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas:
|
||||||
logs = vars_.logs
|
logs = vars_.logs
|
||||||
pair = (logs.weight, logs.weight_day)
|
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)
|
row = _row(col)
|
||||||
for value, text in (("weight", "Weight"), ("fat", "Body fat")):
|
for value, text in (("weight", g.AMOUNT), ("fat", "%")):
|
||||||
tk.Radiobutton(
|
tk.Radiobutton(
|
||||||
row,
|
row,
|
||||||
text=text,
|
text=text,
|
||||||
@ -181,12 +179,13 @@ def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None:
|
|||||||
for name in ("bmi", "ideal", "bmr"):
|
for name in ("bmi", "ideal", "bmr"):
|
||||||
_block(col, vars_.blocks[name])
|
_block(col, vars_.blocks[name])
|
||||||
row = _row(col)
|
row = _row(col)
|
||||||
_label(row, "Week average target kcal")
|
_label(row, f"{g.AVERAGE} kcal")
|
||||||
_entry(row, vars_.plan.target, 6)
|
_entry(row, vars_.plan.target, 6)
|
||||||
for name, var in zip(WEEKDAYS, vars_.plan.days, strict=True):
|
# Seven day boxes under dots, the weekend pair larger (no day names).
|
||||||
_label(row, name)
|
for dot, var in zip(g.WEEKDAY_DOTS, vars_.plan.days, strict=True):
|
||||||
|
_label(row, dot)
|
||||||
_entry(row, var, 5)
|
_entry(row, var, 5)
|
||||||
_button(row, "Plan week", cb.recalc)
|
_button(row, g.OK, cb.recalc)
|
||||||
_block(col, vars_.blocks["plan"])
|
_block(col, vars_.blocks["plan"])
|
||||||
|
|
||||||
|
|
||||||
@ -195,9 +194,6 @@ def build_body_frame(
|
|||||||
) -> BodyWidgets:
|
) -> BodyWidgets:
|
||||||
"""Build the whole tab into ``parent``; return what the controller needs."""
|
"""Build the whole tab into ``parent``; return what the controller needs."""
|
||||||
frame = tk.Frame(parent, bg=BG)
|
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 = tk.Frame(frame, bg=BG)
|
||||||
inner.pack(fill="both", expand=True, padx=MD)
|
inner.pack(fill="both", expand=True, padx=MD)
|
||||||
if profile_first:
|
if profile_first:
|
||||||
@ -215,6 +211,6 @@ def build_body_frame(
|
|||||||
_build_profile(inner, vars_, cb)
|
_build_profile(inner, vars_, cb)
|
||||||
logs = vars_.logs
|
logs = vars_.logs
|
||||||
pair = (logs.body_fat, logs.fat_day)
|
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"])
|
_block(inner, vars_.blocks["bodyfat"])
|
||||||
return BodyWidgets(frame=frame, canvas=canvas)
|
return BodyWidgets(frame=frame, canvas=canvas)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import contextlib
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
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 import BudgetError, write_budget
|
||||||
from diet_guard._budget_derived import budget_weight
|
from diet_guard._budget_derived import budget_weight
|
||||||
from diet_guard._gatelock_mealflow import _GateMealFlow
|
from diet_guard._gatelock_mealflow import _GateMealFlow
|
||||||
@ -79,13 +80,13 @@ class _GateBudgetEdit(_GateMealFlow):
|
|||||||
if not self._cal_editing_budget:
|
if not self._cal_editing_budget:
|
||||||
self._cal_editing_budget = True
|
self._cal_editing_budget = True
|
||||||
self._set_budget_entry_state("normal")
|
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)
|
self._set_budget_status("", error=False)
|
||||||
return
|
return
|
||||||
if not self._save_budget_entry():
|
if not self._save_budget_entry():
|
||||||
return
|
return
|
||||||
self._cal_editing_budget = False
|
self._cal_editing_budget = False
|
||||||
self._set_budget_button_text("Edit")
|
self._set_budget_button_text(g.EDIT)
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
self._refresh_dashboard()
|
self._refresh_dashboard()
|
||||||
self._refresh_projection()
|
self._refresh_projection()
|
||||||
@ -104,15 +105,15 @@ class _GateBudgetEdit(_GateMealFlow):
|
|||||||
try:
|
try:
|
||||||
value = int(raw)
|
value = int(raw)
|
||||||
except ValueError:
|
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
|
return False
|
||||||
if value <= 0:
|
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
|
return False
|
||||||
try:
|
try:
|
||||||
weight = budget_weight()
|
weight = budget_weight()
|
||||||
except BudgetError:
|
except BudgetError:
|
||||||
weight = None
|
weight = None
|
||||||
write_budget(value, weight_kg=weight)
|
write_budget(value, weight_kg=weight)
|
||||||
self._set_budget_status("Saved.", error=False)
|
self._set_budget_status(g.OK, error=False)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -16,12 +16,12 @@ are unit-tested directly without a fake Tk in the loop.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import calendar
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as glyphs
|
||||||
from diet_guard._budget import (
|
from diet_guard._budget import (
|
||||||
BudgetFileCorruptError,
|
BudgetFileCorruptError,
|
||||||
BudgetNotInitializedError,
|
BudgetNotInitializedError,
|
||||||
@ -84,6 +84,7 @@ class _GateCalendar(_GateScheduleEdit):
|
|||||||
_cal_editing_budget: bool
|
_cal_editing_budget: bool
|
||||||
_cal_editing_schedule: bool
|
_cal_editing_schedule: bool
|
||||||
_notebook: ttk.Notebook
|
_notebook: ttk.Notebook
|
||||||
|
_body_tab: BodyTab
|
||||||
|
|
||||||
def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets:
|
def _build_tabs(self, parent: tk.Misc, callbacks: GateCallbacks) -> GateWidgets:
|
||||||
"""Build one monitor's notebook, wire both tabs, return its meal widgets.
|
"""Build one monitor's notebook, wire both tabs, return its meal widgets.
|
||||||
@ -109,7 +110,7 @@ class _GateCalendar(_GateScheduleEdit):
|
|||||||
widgets = build_layout(
|
widgets = build_layout(
|
||||||
notebook, self._vars, callbacks, demo_mode=self.demo_mode
|
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(
|
cal_callbacks = CalendarCallbacks(
|
||||||
on_prev_month=self._on_prev_month,
|
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,
|
on_edit_or_save_schedule=self._on_edit_or_save_schedule,
|
||||||
)
|
)
|
||||||
cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks)
|
cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks)
|
||||||
notebook.add(cal_widgets.frame, text="History")
|
notebook.add(cal_widgets.frame, text=glyphs.TAB_HISTORY)
|
||||||
notebook.add(BodyTab(notebook).container, text="Body")
|
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
|
# Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching
|
||||||
# all live in toplevel bindings that ttk only installs on request. The
|
# 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,
|
# 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
|
schedule = current_schedule(default=budget) if budget is not None else None
|
||||||
if schedule is None:
|
if schedule is None:
|
||||||
self._render_month(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.ytd.set("")
|
||||||
self._cal_vars.averages.set("")
|
self._cal_vars.averages.set("")
|
||||||
else:
|
else:
|
||||||
@ -176,9 +179,8 @@ class _GateCalendar(_GateScheduleEdit):
|
|||||||
def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None:
|
def _render_month(self, status_map_: dict[str, DayStatus] | None) -> None:
|
||||||
"""Redraw the day-cell grid and month label for the displayed month."""
|
"""Redraw the day-cell grid and month label for the displayed month."""
|
||||||
weeks = build_month_cells(self._cal_year, self._cal_month, status_map_)
|
weeks = build_month_cells(self._cal_year, self._cal_month, status_map_)
|
||||||
self._cal_vars.month_label.set(
|
# Numerals, not a month name: "11 · 2026".
|
||||||
f"{calendar.month_name[self._cal_month]} {self._cal_year}",
|
self._cal_vars.month_label.set(f"{self._cal_month:02d} · {self._cal_year}")
|
||||||
)
|
|
||||||
for surface in self._cal_surfaces:
|
for surface in self._cal_surfaces:
|
||||||
for index, cell_widget in enumerate(surface.day_cells):
|
for index, cell_widget in enumerate(surface.day_cells):
|
||||||
row, col = divmod(index, 7)
|
row, col = divmod(index, 7)
|
||||||
|
|||||||
@ -30,6 +30,7 @@ from typing import TYPE_CHECKING
|
|||||||
from gatelock import make_button
|
from gatelock import make_button
|
||||||
|
|
||||||
from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG
|
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_spacing import SM, XS
|
||||||
from diet_guard._gatelock_typography import BODY, LABEL
|
from diet_guard._gatelock_typography import BODY, LABEL
|
||||||
from diet_guard._gatelock_ui import BG, FG
|
from diet_guard._gatelock_ui import BG, FG
|
||||||
@ -168,13 +169,13 @@ def build_schedule_row(
|
|||||||
"""
|
"""
|
||||||
row = tk.Frame(parent, bg=BG)
|
row = tk.Frame(parent, bg=BG)
|
||||||
row.pack(pady=(SM, XS))
|
row.pack(pady=(SM, XS))
|
||||||
_caption(row, "Meals:")
|
_caption(row, CLOCK)
|
||||||
first_spin = _time_spin(row, vars_.schedule.first, FIRST_RANGE)
|
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)
|
last_spin = _time_spin(row, vars_.schedule.last, LAST_RANGE)
|
||||||
_caption(row, "x")
|
_caption(row, TIMES)
|
||||||
count_entry = _spin_entry(row, vars_.schedule.count)
|
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")
|
edit_button.pack(side="left")
|
||||||
|
|
||||||
# The derived checkpoint times, so the effect of a change is visible
|
# The derived checkpoint times, so the effect of a change is visible
|
||||||
|
|||||||
@ -17,6 +17,8 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import LockConfig
|
from gatelock import LockConfig
|
||||||
|
|
||||||
|
from diet_guard._gatelock_glyphs import WEEKDAY, WEEKEND
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
@ -32,7 +34,7 @@ _ACCENT = _COLORS.palette.accent
|
|||||||
|
|
||||||
# calendar.monthcalendar never returns more than 6 weeks for any month.
|
# calendar.monthcalendar never returns more than 6 weeks for any month.
|
||||||
_MONTH_ROWS = 6
|
_MONTH_ROWS = 6
|
||||||
_WEEKDAY_LABELS = ("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")
|
_WEEKDAY_LABELS = (WEEKDAY,) * 5 + (WEEKEND,) * 2
|
||||||
_JANUARY = 1
|
_JANUARY = 1
|
||||||
_DECEMBER = 12
|
_DECEMBER = 12
|
||||||
_MONTH_AFTER_DECEMBER = 13
|
_MONTH_AFTER_DECEMBER = 13
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from gatelock import ButtonStyle, make_button
|
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_schedule import build_schedule_row
|
||||||
from diet_guard._gatelock_calendar_types import (
|
from diet_guard._gatelock_calendar_types import (
|
||||||
_ACCENT,
|
_ACCENT,
|
||||||
@ -33,7 +34,7 @@ from diet_guard._gatelock_calendar_types import (
|
|||||||
CalendarWidgets,
|
CalendarWidgets,
|
||||||
)
|
)
|
||||||
from diet_guard._gatelock_spacing import SM, XS
|
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
|
from diet_guard._gatelock_ui import BG, FG
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -59,7 +60,7 @@ def _build_budget_row(
|
|||||||
row.pack(pady=(SM, XS))
|
row.pack(pady=(SM, XS))
|
||||||
tk.Label(
|
tk.Label(
|
||||||
row,
|
row,
|
||||||
text="Daily budget (kcal):",
|
text=f"{glyphs.BUDGET} kcal",
|
||||||
font=(_COLORS.typography.font_family, LABEL),
|
font=(_COLORS.typography.font_family, LABEL),
|
||||||
bg=BG,
|
bg=BG,
|
||||||
fg=FG,
|
fg=FG,
|
||||||
@ -80,7 +81,7 @@ def _build_budget_row(
|
|||||||
edit_button = make_button(
|
edit_button = make_button(
|
||||||
row,
|
row,
|
||||||
_COLORS,
|
_COLORS,
|
||||||
"Edit",
|
glyphs.EDIT,
|
||||||
on_edit_or_save_budget,
|
on_edit_or_save_budget,
|
||||||
)
|
)
|
||||||
edit_button.pack(side="left")
|
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."""
|
"""Build the fixed 6x7 day-cell grid and return the flat cell list."""
|
||||||
weekday_row = tk.Frame(parent, bg=BG)
|
weekday_row = tk.Frame(parent, bg=BG)
|
||||||
weekday_row.pack()
|
weekday_row.pack()
|
||||||
|
# Seven dots, no day names: the weekend pair is larger and accented.
|
||||||
for col, label in enumerate(_WEEKDAY_LABELS):
|
for col, label in enumerate(_WEEKDAY_LABELS):
|
||||||
|
weekend = label == glyphs.WEEKEND
|
||||||
tk.Label(
|
tk.Label(
|
||||||
weekday_row,
|
weekday_row,
|
||||||
text=label,
|
text=label,
|
||||||
font=(_COLORS.typography.font_family, CAPTION, "bold"),
|
font=(_COLORS.typography.font_family, CAPTION, "bold"),
|
||||||
bg=BG,
|
bg=BG,
|
||||||
fg=_MUTED,
|
fg=_ACCENT if weekend else _MUTED,
|
||||||
width=4,
|
width=4,
|
||||||
).grid(row=0, column=col, padx=XS)
|
).grid(row=0, column=col, padx=XS)
|
||||||
|
|
||||||
@ -170,14 +173,6 @@ def build_calendar_frame(
|
|||||||
) -> CalendarWidgets:
|
) -> CalendarWidgets:
|
||||||
"""Lay out the History tab and return the widgets the controller drives."""
|
"""Lay out the History tab and return the widgets the controller drives."""
|
||||||
frame = tk.Frame(root, bg=BG)
|
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(
|
budget_entry, budget_edit_button, budget_status_label = _build_budget_row(
|
||||||
frame,
|
frame,
|
||||||
vars_,
|
vars_,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import logging
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from diet_guard._gatelock_glyphs import CLOCK, OK
|
||||||
from diet_guard._gatelock_ui import (
|
from diet_guard._gatelock_ui import (
|
||||||
BASIS_PREFIX_GRAMS,
|
BASIS_PREFIX_GRAMS,
|
||||||
BASIS_PREFIX_ITEMS,
|
BASIS_PREFIX_ITEMS,
|
||||||
@ -108,11 +109,11 @@ class _GateCore:
|
|||||||
"""Update the header to prompt for the slot now being collected."""
|
"""Update the header to prompt for the slot now being collected."""
|
||||||
total = len(self._pending)
|
total = len(self._pending)
|
||||||
if total == 0:
|
if total == 0:
|
||||||
self._vars.slot_header.set("All meals logged.")
|
self._vars.slot_header.set(OK)
|
||||||
return
|
return
|
||||||
slot = self._pending[0]
|
slot = self._pending[0]
|
||||||
position = "" if total == 1 else f" (1 of {total} remaining)"
|
position = "" if total == 1 else f" 1/{total}"
|
||||||
self._vars.slot_header.set(f"Log your {slot_label(slot)} meal{position}")
|
self._vars.slot_header.set(f"{CLOCK} {slot_label(slot)}{position}")
|
||||||
|
|
||||||
def _reset_per_default(self) -> None:
|
def _reset_per_default(self) -> None:
|
||||||
"""Set the "per" field to the basis default for the current unit."""
|
"""Set the "per" field to the basis default for the current unit."""
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import queue
|
|||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gate import due_slots
|
from diet_guard._gate import due_slots
|
||||||
from diet_guard._gatelock_fetch import FETCH_POLL_MS, start_fetch
|
from diet_guard._gatelock_fetch import FETCH_POLL_MS, start_fetch
|
||||||
from diet_guard._gatelock_kuchnia import start_delivery_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")
|
_LAZY_ATTRS = ("refresh_delivery", "refresh_delivery_once")
|
||||||
|
|
||||||
#: ``(unlock reason, nothing-new status)`` for a sync pull; "Fill all" has its own.
|
#: ``(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:
|
def __getattr__(name: str) -> object:
|
||||||
@ -104,12 +105,12 @@ class _PullFlows(_GateNutrition):
|
|||||||
second click while a fetch is in flight is ignored.
|
second click while a fetch is in flight is ignored.
|
||||||
"""
|
"""
|
||||||
if self.demo_mode:
|
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
|
return
|
||||||
if self._delivery_result is not None:
|
if self._delivery_result is not None:
|
||||||
return
|
return
|
||||||
self._delivery_asked = True
|
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
|
# Through the module object so the lazy hook resolves it, and so a
|
||||||
# test's ``patch.object`` still wins.
|
# test's ``patch.object`` still wins.
|
||||||
self._delivery_result = start_delivery_fetch(
|
self._delivery_result = start_delivery_fetch(
|
||||||
@ -131,11 +132,11 @@ class _PullFlows(_GateNutrition):
|
|||||||
self._delivery_result = None
|
self._delivery_result = None
|
||||||
if outcome.reason is not None:
|
if outcome.reason is not None:
|
||||||
if self._delivery_asked:
|
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
|
return
|
||||||
if not outcome.dishes:
|
if not outcome.dishes:
|
||||||
if self._delivery_asked:
|
if self._delivery_asked:
|
||||||
self._set_status("No catering delivery today.")
|
self._set_status(f"{g.EMPTY} {g.DELIVERY}")
|
||||||
return
|
return
|
||||||
self._delivery_pending = dishes_in_slot_order(outcome.dishes)
|
self._delivery_pending = dishes_in_slot_order(outcome.dishes)
|
||||||
self._prefill_next_dish()
|
self._prefill_next_dish()
|
||||||
@ -160,11 +161,9 @@ class _PullFlows(_GateNutrition):
|
|||||||
# dish at 3.5x its calories while the fields still read correctly.
|
# dish at 3.5x its calories while the fields still read correctly.
|
||||||
self._apply_reference(dish_nutrition(dish), name=dish.name)
|
self._apply_reference(dish_nutrition(dish), name=dish.name)
|
||||||
remaining = len(self._delivery_pending)
|
remaining = len(self._delivery_pending)
|
||||||
suffix = f" ({remaining} more to go)" if remaining else ""
|
suffix = f" +{remaining}" if remaining else ""
|
||||||
prefix = f"{logged} — " if logged else ""
|
prefix = f"{logged} " if logged else ""
|
||||||
self._set_status(
|
self._set_status(f"{prefix}{g.DELIVERY} {dish.name}{suffix}")
|
||||||
f"{prefix}Loaded: {dish.name}{suffix} — check, then Log & Continue."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _autoload_delivery(self) -> None:
|
def _autoload_delivery(self) -> None:
|
||||||
"""Start a guarded catering fetch as the lock opens.
|
"""Start a guarded catering fetch as the lock opens.
|
||||||
@ -203,13 +202,13 @@ class _PullFlows(_GateNutrition):
|
|||||||
write-race fix in one.
|
write-race fix in one.
|
||||||
"""
|
"""
|
||||||
if self.demo_mode:
|
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
|
return
|
||||||
if self._fetch_result is not None:
|
if self._fetch_result is not None:
|
||||||
# Already fetching. Without this a double-click starts a second
|
# Already fetching. Without this a double-click starts a second
|
||||||
# worker, orphans the first result and races two log writes.
|
# worker, orphans the first result and races two log writes.
|
||||||
return
|
return
|
||||||
self._set_status("Fetching from sync…")
|
self._set_status(f"{g.BUSY} {g.SYNC}")
|
||||||
self._fetch_result = start_fetch(pull_peer_logs)
|
self._fetch_result = start_fetch(pull_peer_logs)
|
||||||
self.root.after(FETCH_POLL_MS, self._poll_fetch)
|
self.root.after(FETCH_POLL_MS, self._poll_fetch)
|
||||||
|
|
||||||
@ -225,7 +224,7 @@ class _PullFlows(_GateNutrition):
|
|||||||
return
|
return
|
||||||
self._fetch_result = None
|
self._fetch_result = None
|
||||||
if reason is not 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
|
return
|
||||||
self._reconcile_after_fetch()
|
self._reconcile_after_fetch()
|
||||||
|
|
||||||
@ -244,7 +243,5 @@ class _PullFlows(_GateNutrition):
|
|||||||
return
|
return
|
||||||
self._clear_inputs()
|
self._clear_inputs()
|
||||||
self._refresh_slot_header()
|
self._refresh_slot_header()
|
||||||
count = len(satisfied_slots)
|
self._set_status(f"{g.OK} {g.SYNC} {len(satisfied_slots)}")
|
||||||
meal_word = "meal" if count == 1 else "meals"
|
|
||||||
self._set_status(f"Pulled {count} {meal_word} — next meal, please.")
|
|
||||||
self._widgets.desc_text.focus_set()
|
self._widgets.desc_text.focus_set()
|
||||||
|
|||||||
@ -22,9 +22,10 @@ from __future__ import annotations
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as glyphs
|
||||||
from diet_guard import _gatelock_ui
|
from diet_guard import _gatelock_ui
|
||||||
from diet_guard._gatelock_spacing import SM, XS
|
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 (
|
from diet_guard._gatelock_ui import (
|
||||||
_ACCENT,
|
_ACCENT,
|
||||||
_COLORS,
|
_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
|
wraps onto a second line and stays fully visible, instead of scrolling off
|
||||||
the right edge where the end can no longer be read.
|
the right edge where the end can no longer be read.
|
||||||
"""
|
"""
|
||||||
tk.Label(
|
tk.Label(parent, text=glyphs.FOOD, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack()
|
||||||
parent,
|
|
||||||
text="What did you eat?",
|
|
||||||
font=(_FONT, LABEL),
|
|
||||||
bg=BG,
|
|
||||||
fg=FG,
|
|
||||||
).pack()
|
|
||||||
text = tk.Text(
|
text = tk.Text(
|
||||||
parent,
|
parent,
|
||||||
font=(_FONT, BODY),
|
font=(_FONT, BODY),
|
||||||
@ -121,13 +116,7 @@ def build_amount_row(
|
|||||||
on_unit_change: Callable[[str], None],
|
on_unit_change: Callable[[str], None],
|
||||||
) -> tk.Entry:
|
) -> tk.Entry:
|
||||||
"""Build the "how much did you eat?" amount + unit row; return the entry."""
|
"""Build the "how much did you eat?" amount + unit row; return the entry."""
|
||||||
tk.Label(
|
tk.Label(parent, text=glyphs.AMOUNT, font=(_FONT, SUBTITLE), bg=BG, fg=FG).pack()
|
||||||
parent,
|
|
||||||
text="How much did you eat?",
|
|
||||||
font=(_FONT, LABEL),
|
|
||||||
bg=BG,
|
|
||||||
fg=FG,
|
|
||||||
).pack()
|
|
||||||
row = tk.Frame(parent, bg=BG)
|
row = tk.Frame(parent, bg=BG)
|
||||||
row.pack(pady=(XS, XS))
|
row.pack(pady=(XS, XS))
|
||||||
amount_entry = _numeric_entry(root, row, width=10, variable=vars_.entries.amount)
|
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):
|
for unit in (UNIT_GRAMS, UNIT_ITEMS):
|
||||||
tk.Radiobutton(
|
tk.Radiobutton(
|
||||||
row,
|
row,
|
||||||
text=unit,
|
text=glyphs.ITEMS if unit == UNIT_ITEMS else glyphs.GRAMS,
|
||||||
value=unit,
|
value=unit,
|
||||||
variable=vars_.unit,
|
variable=vars_.unit,
|
||||||
command=lambda u=unit: on_unit_change(u),
|
command=lambda u=unit: on_unit_change(u),
|
||||||
@ -203,20 +192,21 @@ def build_macro_section(
|
|||||||
basis_prefix.pack(side="left")
|
basis_prefix.pack(side="left")
|
||||||
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
|
per_entry = _numeric_entry(root, basis, width=5, variable=vars_.entries.per)
|
||||||
per_entry.pack(side="left", padx=XS, ipady=XS)
|
per_entry.pack(side="left", padx=XS, ipady=XS)
|
||||||
tk.Label(
|
# Blank kcal still means "look it up"; the busy cue shows it happening.
|
||||||
basis,
|
tk.Label(basis, text="g", font=(_FONT, LABEL), bg=BG, fg=FG).pack(side="left")
|
||||||
text="g (leave calories blank to look it up):",
|
|
||||||
font=(_FONT, LABEL),
|
|
||||||
bg=BG,
|
|
||||||
fg=FG,
|
|
||||||
).pack(side="left")
|
|
||||||
|
|
||||||
row = tk.Frame(parent, bg=BG)
|
row = tk.Frame(parent, bg=BG)
|
||||||
row.pack(pady=(XS, XS))
|
row.pack(pady=(XS, XS))
|
||||||
|
# Shape + colour per macro, the phone's MacroKind vocabulary.
|
||||||
|
colors = glyphs.MACRO_COLORS
|
||||||
macros = _MacroEntries(
|
macros = _MacroEntries(
|
||||||
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
|
kcal=_macro_cell(root, row, "kcal", vars_.entries.kcal),
|
||||||
protein=_macro_cell(root, row, "P", vars_.entries.protein),
|
protein=_macro_cell(
|
||||||
carbs=_macro_cell(root, row, "C", vars_.entries.carbs),
|
root, row, glyphs.PROTEIN, vars_.entries.protein, colors["protein"]
|
||||||
fat=_macro_cell(root, row, "F", vars_.entries.fat),
|
),
|
||||||
|
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
|
return basis_prefix, per_entry, macros
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from importlib import import_module
|
|||||||
import queue
|
import queue
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
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_fetch import FETCH_POLL_MS
|
||||||
from diet_guard._gatelock_kuchnia import start_delivery_fetch
|
from diet_guard._gatelock_kuchnia import start_delivery_fetch
|
||||||
from diet_guard._kuchnia_log import fill_plan, log_dishes
|
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
|
from diet_guard._kuchnia_spread import SlottedDish
|
||||||
|
|
||||||
#: The button's resting label; also what it returns to after each attempt.
|
#: 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:
|
#: ``(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 = (
|
RECONCILE_LABELS = (
|
||||||
"Filled from catering",
|
f"{g.OK} {g.DELIVERY}",
|
||||||
"Catering logged; this slot still needs a meal.",
|
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]:
|
def _refresh_delivery(day: date) -> tuple[Sequence[Dish], str | None]:
|
||||||
"""Fetch ``day``'s dishes, importing the HTTP stack only when clicked.
|
"""Fetch ``day``'s dishes, importing the HTTP stack only when clicked.
|
||||||
@ -103,7 +108,7 @@ class FillAllFlow:
|
|||||||
def click(self) -> None:
|
def click(self) -> None:
|
||||||
"""First click fetches and proposes; a second click confirms."""
|
"""First click fetches and proposes; a second click confirms."""
|
||||||
if self._demo_mode:
|
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
|
return
|
||||||
if self._result is not None:
|
if self._result is not None:
|
||||||
return # a fetch is in flight; a double-click must not start two
|
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.
|
# at midnight can never confirm yesterday's menu into today.
|
||||||
self._reset()
|
self._reset()
|
||||||
self._plan_day = today
|
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._result = start_delivery_fetch(_refresh_delivery, today)
|
||||||
self._root.after(FETCH_POLL_MS, self._poll)
|
self._root.after(FETCH_POLL_MS, self._poll)
|
||||||
|
|
||||||
@ -132,24 +137,23 @@ class FillAllFlow:
|
|||||||
return
|
return
|
||||||
self._result = None
|
self._result = None
|
||||||
if outcome.reason is not 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
|
return
|
||||||
if not outcome.dishes:
|
if not outcome.dishes:
|
||||||
self._set_status("No catering delivery today.")
|
self._set_status(f"{g.EMPTY} {g.DELIVERY}")
|
||||||
return
|
return
|
||||||
plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule())))
|
plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule())))
|
||||||
if not plan:
|
if not plan:
|
||||||
self._set_status("Every slot already has a meal — nothing to fill.")
|
self._set_status(_NOTHING_TO_FILL)
|
||||||
return
|
return
|
||||||
self._plan = plan
|
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)
|
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
|
f"{slot_label(item.slot)} {item.dish.name}" for item in plan
|
||||||
)
|
)
|
||||||
self._set_status(
|
self._set_status(f"{g.DELIVERY} {len(plan)} {total:g} kcal {listing}")
|
||||||
f"Will log {len(plan)} ({total:g} kcal): {listing} — click Confirm."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _confirm(self) -> None:
|
def _confirm(self) -> None:
|
||||||
"""Write the proposed dishes into the slots still empty right now."""
|
"""Write the proposed dishes into the slots still empty right now."""
|
||||||
@ -158,10 +162,10 @@ class FillAllFlow:
|
|||||||
plan = fill_plan(self._plan)
|
plan = fill_plan(self._plan)
|
||||||
self._reset()
|
self._reset()
|
||||||
if not plan:
|
if not plan:
|
||||||
self._set_status("Every slot already has a meal — nothing to fill.")
|
self._set_status(_NOTHING_TO_FILL)
|
||||||
return
|
return
|
||||||
written = log_dishes(plan)
|
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()
|
self._on_logged()
|
||||||
|
|
||||||
def _reset(self) -> None:
|
def _reset(self) -> None:
|
||||||
|
|||||||
88
diet_guard/_gatelock_glyphs.py
Normal file
88
diet_guard/_gatelock_glyphs.py
Normal file
@ -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, "≈")
|
||||||
@ -18,6 +18,7 @@ import tkinter as tk
|
|||||||
|
|
||||||
from gatelock import ButtonStyle, ScrollableSurface, make_button
|
from gatelock import ButtonStyle, ScrollableSurface, make_button
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as glyphs
|
||||||
from diet_guard._gatelock_fields import (
|
from diet_guard._gatelock_fields import (
|
||||||
build_amount_row,
|
build_amount_row,
|
||||||
build_desc,
|
build_desc,
|
||||||
@ -66,14 +67,7 @@ def _build_dashboard(parent: tk.Frame, vars_: GateVars) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _build_header(body: tk.Frame, vars_: GateVars) -> None:
|
def _build_header(body: tk.Frame, vars_: GateVars) -> None:
|
||||||
"""Pack the title and the current-slot header."""
|
"""Pack the current-slot header (no title: the tab glyph names the tab)."""
|
||||||
tk.Label(
|
|
||||||
body,
|
|
||||||
text="🍽 Diet Gate",
|
|
||||||
font=(_FONT, TITLE, "bold"),
|
|
||||||
bg=BG,
|
|
||||||
fg=_ACCENT,
|
|
||||||
).pack(pady=(0, XS))
|
|
||||||
tk.Label(
|
tk.Label(
|
||||||
body,
|
body,
|
||||||
textvariable=vars_.slot_header,
|
textvariable=vars_.slot_header,
|
||||||
@ -114,7 +108,7 @@ def _build_actions(
|
|||||||
make_button(
|
make_button(
|
||||||
body,
|
body,
|
||||||
_COLORS,
|
_COLORS,
|
||||||
"Log & Continue",
|
glyphs.SUBMIT,
|
||||||
callbacks.on_submit,
|
callbacks.on_submit,
|
||||||
).pack(pady=(XS, XS))
|
).pack(pady=(XS, XS))
|
||||||
|
|
||||||
@ -129,7 +123,7 @@ def _build_actions(
|
|||||||
make_button(
|
make_button(
|
||||||
pulls,
|
pulls,
|
||||||
_COLORS,
|
_COLORS,
|
||||||
"⟳ Fetch from sync",
|
glyphs.SYNC,
|
||||||
callbacks.on_fetch_sync,
|
callbacks.on_fetch_sync,
|
||||||
ButtonStyle(variant="secondary", bold=False),
|
ButtonStyle(variant="secondary", bold=False),
|
||||||
).pack(side="left", padx=(0, XS))
|
).pack(side="left", padx=(0, XS))
|
||||||
@ -140,7 +134,7 @@ def _build_actions(
|
|||||||
make_button(
|
make_button(
|
||||||
pulls,
|
pulls,
|
||||||
_COLORS,
|
_COLORS,
|
||||||
"🍱 Today's delivery",
|
glyphs.DELIVERY,
|
||||||
callbacks.on_load_delivery,
|
callbacks.on_load_delivery,
|
||||||
ButtonStyle(variant="secondary", bold=False),
|
ButtonStyle(variant="secondary", bold=False),
|
||||||
).pack(side="left", padx=(0, XS))
|
).pack(side="left", padx=(0, XS))
|
||||||
@ -222,10 +216,12 @@ def build_layout(
|
|||||||
make_button(
|
make_button(
|
||||||
root,
|
root,
|
||||||
_COLORS,
|
_COLORS,
|
||||||
"✕ Close Demo",
|
glyphs.CLOSE,
|
||||||
callbacks.on_close,
|
callbacks.on_close,
|
||||||
ButtonStyle(variant="danger", bold=False),
|
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(
|
return GateWidgets(
|
||||||
frame=frame,
|
frame=frame,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from functools import partial
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
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 import BudgetError, daily_budget
|
||||||
from diet_guard._budget_derived import protein_target_g
|
from diet_guard._budget_derived import protein_target_g
|
||||||
from diet_guard._foodbank import remember_food
|
from diet_guard._foodbank import remember_food
|
||||||
@ -92,13 +93,13 @@ class _GateMealFlow(_PullFlows):
|
|||||||
"""Validate, then look the food up, or log it."""
|
"""Validate, then look the food up, or log it."""
|
||||||
description = self._get_desc()
|
description = self._get_desc()
|
||||||
if not description:
|
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()
|
self._widgets.desc_text.focus_set()
|
||||||
return
|
return
|
||||||
|
|
||||||
values = self._macro_values()
|
values = self._macro_values()
|
||||||
if values is None:
|
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()
|
self._widgets.macros.kcal.focus_set()
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -107,7 +108,7 @@ class _GateMealFlow(_PullFlows):
|
|||||||
return
|
return
|
||||||
nutrition = self._current_nutrition()
|
nutrition = self._current_nutrition()
|
||||||
if nutrition is None:
|
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()
|
self._widgets.macros.kcal.focus_set()
|
||||||
return
|
return
|
||||||
self._record(description, nutrition)
|
self._record(description, nutrition)
|
||||||
@ -120,31 +121,28 @@ class _GateMealFlow(_PullFlows):
|
|||||||
up at its natural basis (per 100 g / serving); the amount eaten scales
|
up at its natural basis (per 100 g / serving); the amount eaten scales
|
||||||
it, so the lookup never bakes in a portion.
|
it, so the lookup never bakes in a portion.
|
||||||
"""
|
"""
|
||||||
self._set_status("looking up…")
|
self._set_status(g.BUSY)
|
||||||
self.root.update_idletasks()
|
self.root.update_idletasks()
|
||||||
candidates = lookup_candidates(description)
|
candidates = lookup_candidates(description)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
self._set_status(
|
# Not found: the kcal field takes focus, the cue names it.
|
||||||
"Couldn't look that up. Enter the calories yourself, then submit.",
|
self._set_status(f"{g.ERROR} {g.source_glyph('')} kcal", error=True)
|
||||||
error=True,
|
|
||||||
)
|
|
||||||
self._widgets.macros.kcal.focus_set()
|
self._widgets.macros.kcal.focus_set()
|
||||||
return
|
return
|
||||||
self._show_candidates(candidates)
|
self._show_candidates(candidates)
|
||||||
self._apply_reference(candidates[0][1])
|
self._apply_reference(candidates[0][1])
|
||||||
source = candidates[0][1].source
|
# Filled, not logged: the source glyph, and the alternatives' count
|
||||||
tail = (
|
# when the picker below offers more than one.
|
||||||
"Review, or pick another below, then submit to log."
|
source = g.source_glyph(candidates[0][1].source)
|
||||||
if len(candidates) > 1
|
more = f" {g.TIMES}{len(candidates)}" if len(candidates) > 1 else ""
|
||||||
else "Review the values, then submit to log."
|
self._set_status(f"{g.OK} {source}{more}")
|
||||||
)
|
|
||||||
self._set_status(f"Filled from {source}. {tail}")
|
|
||||||
|
|
||||||
def _record(self, description: str, nutrition: Nutrition) -> None:
|
def _record(self, description: str, nutrition: Nutrition) -> None:
|
||||||
"""Log and bank a single food for the current slot, then advance."""
|
"""Log and bank a single food for the current slot, then advance."""
|
||||||
log_meal(description, nutrition, self._slot_for_log())
|
log_meal(description, nutrition, self._slot_for_log())
|
||||||
remember_food(description, nutrition)
|
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:
|
def _slot_for_log(self) -> int | None:
|
||||||
"""Return the slot to tag a log with -- None in demo (satisfies no slot).
|
"""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]
|
slot = self._pending[0]
|
||||||
self._pending.pop(0)
|
self._pending.pop(0)
|
||||||
self._refresh_dashboard()
|
self._refresh_dashboard()
|
||||||
logged = f"Logged {slot_label(slot)}: {summary}"
|
logged = f"{g.OK} {slot_label(slot)} {summary}"
|
||||||
if not self._pending:
|
if not self._pending:
|
||||||
self._unlock(logged)
|
self._unlock(logged)
|
||||||
return
|
return
|
||||||
@ -179,7 +177,7 @@ class _GateMealFlow(_PullFlows):
|
|||||||
if self._delivery_pending:
|
if self._delivery_pending:
|
||||||
self._prefill_next_dish(logged)
|
self._prefill_next_dish(logged)
|
||||||
return
|
return
|
||||||
self._set_status(f"{logged} — next meal, please.")
|
self._set_status(logged)
|
||||||
self._widgets.desc_text.focus_set()
|
self._widgets.desc_text.focus_set()
|
||||||
|
|
||||||
def _unlock(self, logged: str) -> None:
|
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
|
d.name.strip().casefold() not in eaten for d in self._delivery_pending
|
||||||
)
|
)
|
||||||
if left:
|
if left:
|
||||||
noun = "dish" if left == 1 else "dishes"
|
logged = f"{logged} {g.DELIVERY} +{left}"
|
||||||
logged = f"{logged} ({left} more {noun} delivered; log with 'ate')"
|
self._set_status(f"{logged} {g.UNLOCKING}")
|
||||||
self._set_status(f"{logged} — all meals logged, unlocking…")
|
|
||||||
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
||||||
|
|
||||||
def _refresh_dashboard(self) -> None:
|
def _refresh_dashboard(self) -> None:
|
||||||
@ -212,14 +209,13 @@ class _GateMealFlow(_PullFlows):
|
|||||||
try:
|
try:
|
||||||
budget = daily_budget()
|
budget = daily_budget()
|
||||||
except BudgetError, OSError:
|
except BudgetError, OSError:
|
||||||
return f"{consumed:g} kcal today"
|
return f"{consumed:g} kcal"
|
||||||
return (
|
left = round(budget - consumed, 1)
|
||||||
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"
|
return f"{consumed:g} / {budget:g} kcal {g.BUDGET} {left:g}"
|
||||||
)
|
|
||||||
|
|
||||||
def _dashboard_text(self) -> str:
|
def _dashboard_text(self) -> str:
|
||||||
"""Build the detail panel: recent meals, then macros and protein."""
|
"""Build the detail panel: recent meals, then macros and protein."""
|
||||||
lines = ["── Today ───────────────────────────────"]
|
lines = ["────────────────────────────────────────"]
|
||||||
entries = today_entries()
|
entries = today_entries()
|
||||||
if entries:
|
if entries:
|
||||||
for entry in entries[-_DASHBOARD_ROWS:]:
|
for entry in entries[-_DASHBOARD_ROWS:]:
|
||||||
@ -230,20 +226,21 @@ class _GateMealFlow(_PullFlows):
|
|||||||
f"{entry_kcal(entry):>5.0f} kcal",
|
f"{entry_kcal(entry):>5.0f} kcal",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(" (nothing logged yet today)")
|
lines.append(f" {g.EMPTY}")
|
||||||
protein, carbs, fat = today_total_macros()
|
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()
|
target = protein_target_g()
|
||||||
if target is not None:
|
if target is not None:
|
||||||
left = round(target - protein, 1)
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def on_callback_error(self) -> None:
|
def on_callback_error(self) -> None:
|
||||||
"""Surface an unexpected callback error without dropping the grab."""
|
"""Surface an unexpected callback error without dropping the grab."""
|
||||||
self._set_status(
|
self._set_status(f"{g.WARN} kcal", error=True)
|
||||||
"Something went wrong. Enter the calories, then submit again.",
|
|
||||||
error=True,
|
|
||||||
)
|
|
||||||
with contextlib.suppress(tk.TclError):
|
with contextlib.suppress(tk.TclError):
|
||||||
self._widgets.macros.kcal.focus_set()
|
self._widgets.macros.kcal.focus_set()
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._budget import BudgetError, daily_budget
|
from diet_guard._budget import BudgetError, daily_budget
|
||||||
from diet_guard._estimator import Nutrition, scale_nutrition
|
from diet_guard._estimator import Nutrition, scale_nutrition
|
||||||
from diet_guard._gatelock_core import _GateCore
|
from diet_guard._gatelock_core import _GateCore
|
||||||
@ -32,10 +33,13 @@ from diet_guard._state_today import (
|
|||||||
def _format_preview(nutrition: Nutrition) -> str:
|
def _format_preview(nutrition: Nutrition) -> str:
|
||||||
"""Render the one-line "this is what will be logged" preview."""
|
"""Render the one-line "this is what will be logged" preview."""
|
||||||
portion = f" · {nutrition.grams:g}g" if nutrition.grams else ""
|
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 (
|
return (
|
||||||
f"→ {nutrition.kcal:g} kcal · "
|
f"→ {nutrition.kcal:g} kcal · {g.PROTEIN} {nutrition.protein_g:g} "
|
||||||
f"P{nutrition.protein_g:g} C{nutrition.carbs_g:g} F{nutrition.fat_g:g}"
|
f"{g.CARBS} {nutrition.carbs_g:g} {g.FAT} {nutrition.fat_g:g}"
|
||||||
f"{portion} · {nutrition.source}"
|
f"{portion} · {source}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -98,14 +102,14 @@ class _GateNutrition(_GateCore):
|
|||||||
try:
|
try:
|
||||||
budget = daily_budget()
|
budget = daily_budget()
|
||||||
except BudgetError, OSError:
|
except BudgetError, OSError:
|
||||||
tail = f" · this item {this_kcal:g} kcal" if this_kcal else ""
|
tail = f" + {this_kcal:g}" if this_kcal else ""
|
||||||
self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}")
|
self._vars.projection.set(f"{consumed:g} kcal{tail}")
|
||||||
return
|
return
|
||||||
left = round(budget - consumed, 1)
|
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:
|
if this_kcal:
|
||||||
after = round(budget - consumed - this_kcal, 1)
|
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:
|
else:
|
||||||
self._vars.projection.set(base)
|
self._vars.projection.set(base)
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import abc
|
|||||||
import contextlib
|
import contextlib
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
|
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
|
||||||
from diet_guard._gatelock_ui import ERR, FG
|
from diet_guard._gatelock_ui import ERR, FG
|
||||||
from diet_guard._meal_schedule import (
|
from diet_guard._meal_schedule import (
|
||||||
@ -57,14 +58,14 @@ def schedule_problem(schedule: MealSchedule) -> str | None:
|
|||||||
a checkpoint.
|
a checkpoint.
|
||||||
"""
|
"""
|
||||||
first, last = schedule.first_minute, schedule.last_minute
|
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:
|
if last < first + SLOT_GRID_MINUTES:
|
||||||
return (
|
return f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ {SLOT_GRID_MINUTES} min"
|
||||||
f"The last meal must be at least {SLOT_GRID_MINUTES} min after the first."
|
|
||||||
)
|
|
||||||
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
|
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
|
||||||
return f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}."
|
return f"{g.ERROR} {g.TIMES} {MIN_MEAL_COUNT}{g.DASH}{MAX_MEAL_COUNT}"
|
||||||
if schedule.count > (last - first) // SLOT_GRID_MINUTES + 1:
|
capacity = (last - first) // SLOT_GRID_MINUTES + 1
|
||||||
return "Too many meals for that window -- widen it or eat less often."
|
if schedule.count > capacity:
|
||||||
|
return f"{g.ERROR} {g.TIMES} ≤ {capacity}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -127,14 +128,14 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
if not self._cal_editing_schedule:
|
if not self._cal_editing_schedule:
|
||||||
self._cal_editing_schedule = True
|
self._cal_editing_schedule = True
|
||||||
self._set_schedule_entry_state("normal")
|
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)
|
self._set_schedule_status("", error=False)
|
||||||
return
|
return
|
||||||
if not self._save_schedule_entry():
|
if not self._save_schedule_entry():
|
||||||
return
|
return
|
||||||
self._cal_editing_schedule = False
|
self._cal_editing_schedule = False
|
||||||
self._set_schedule_entry_state("readonly")
|
self._set_schedule_entry_state("readonly")
|
||||||
self._set_schedule_button_text("Edit")
|
self._set_schedule_button_text(g.EDIT)
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
|
|
||||||
def _read_schedule_fields(self) -> MealSchedule | None:
|
def _read_schedule_fields(self) -> MealSchedule | None:
|
||||||
@ -143,14 +144,12 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
first = parse_hhmm(self._cal_vars.schedule.first.get())
|
first = parse_hhmm(self._cal_vars.schedule.first.get())
|
||||||
last = parse_hhmm(self._cal_vars.schedule.last.get())
|
last = parse_hhmm(self._cal_vars.schedule.last.get())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self._set_schedule_status(
|
self._set_schedule_status(f"{g.ERROR} {g.CLOCK}", error=True)
|
||||||
"Enter meal times as HH:MM, e.g. 07:15.", error=True
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
count = int(self._cal_vars.schedule.count.get().strip())
|
count = int(self._cal_vars.schedule.count.get().strip())
|
||||||
except ValueError:
|
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 None
|
||||||
return MealSchedule(first, last, count)
|
return MealSchedule(first, last, count)
|
||||||
|
|
||||||
@ -169,5 +168,5 @@ class _GateScheduleEdit(_GateBudgetEdit):
|
|||||||
return False
|
return False
|
||||||
record_schedule_change(schedule)
|
record_schedule_change(schedule)
|
||||||
self._show_schedule(current_schedule())
|
self._show_schedule(current_schedule())
|
||||||
self._set_schedule_status("Saved.", error=False)
|
self._set_schedule_status(g.OK, error=False)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -26,6 +26,7 @@ from gatelock import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from diet_guard._gatelock_fillall import FILL_LABEL
|
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_spacing import SM, XS
|
||||||
from diet_guard._gatelock_typography import (
|
from diet_guard._gatelock_typography import (
|
||||||
BODY,
|
BODY,
|
||||||
@ -60,8 +61,8 @@ DEFAULT_PER_GRAMS = 100.0
|
|||||||
UNIT_GRAMS = "grams"
|
UNIT_GRAMS = "grams"
|
||||||
UNIT_ITEMS = "items"
|
UNIT_ITEMS = "items"
|
||||||
# Per-basis label prefixes for the two measuring modes.
|
# Per-basis label prefixes for the two measuring modes.
|
||||||
BASIS_PREFIX_GRAMS = "Nutrition as on the label — per"
|
BASIS_PREFIX_GRAMS = "/"
|
||||||
BASIS_PREFIX_ITEMS = "Nutrition per 1 item ≈"
|
BASIS_PREFIX_ITEMS = f"{TIMES}1 ≈"
|
||||||
# Wrap width for the gate's prose labels, in px. 640 is tokens.md's line-length
|
# 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,
|
# 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
|
# which both exceeded the cap and -- more practically -- forced a 900px-wide
|
||||||
@ -137,12 +138,16 @@ def _numeric_entry(
|
|||||||
|
|
||||||
|
|
||||||
def _macro_cell(
|
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:
|
) -> 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 = tk.Frame(row, bg=BG)
|
||||||
cell.pack(side="left", padx=SM)
|
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 = _numeric_entry(root, cell, width=7, variable=variable)
|
||||||
entry.pack(ipady=XS)
|
entry.pack(ipady=XS)
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date
|
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._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule
|
||||||
from diet_guard._calendar_view import averages_text
|
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:
|
class TestAveragesText:
|
||||||
def test_names_both_periods(self) -> None:
|
def test_names_both_periods(self) -> None:
|
||||||
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
|
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
|
||||||
assert "week" in text
|
assert _WEEK in text
|
||||||
assert "month" 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))
|
text = averages_text({}, schedule=_flat(2000), today=date(2026, 1, 14))
|
||||||
assert "to yesterday" in text
|
assert text == f"{_WEEK}{g.NO_VALUE} {_MONTH}{g.NO_VALUE}"
|
||||||
|
|
||||||
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 "0 kcal" not in text
|
assert "0 kcal" not in text
|
||||||
|
|
||||||
def test_renders_the_average_and_its_band(self) -> None:
|
def test_renders_the_average_and_its_band(self) -> None:
|
||||||
# Mon-Tue of the week containing Wed 2026-01-14, at 3000 kcal each
|
# 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 = {
|
log = {
|
||||||
"2026-01-12": [{"kcal": 3000.0}],
|
"2026-01-12": [{"kcal": 3000.0}],
|
||||||
"2026-01-13": [{"kcal": 3000.0}],
|
"2026-01-13": [{"kcal": 3000.0}],
|
||||||
}
|
}
|
||||||
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
|
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}]}
|
log = {"2026-01-12": [{"kcal": 1500.0}]}
|
||||||
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
|
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}]}
|
log = {"2026-01-12": [{"kcal": 2200.0}]}
|
||||||
text = averages_text(log, schedule=_flat(2000), today=date(2026, 1, 14))
|
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:
|
def test_today_is_not_averaged_in(self) -> None:
|
||||||
log = {"2026-01-14": [{"kcal": 9000.0}]}
|
log = {"2026-01-14": [{"kcal": 9000.0}]}
|
||||||
@ -56,4 +57,4 @@ class TestAveragesText:
|
|||||||
assert "9000" not in text
|
assert "9000" not in text
|
||||||
|
|
||||||
def test_defaults_to_the_real_today(self) -> None:
|
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))
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._calendar_view import (
|
from diet_guard._calendar_view import (
|
||||||
CalendarCell,
|
CalendarCell,
|
||||||
build_month_cells,
|
build_month_cells,
|
||||||
@ -103,23 +104,22 @@ class TestCellStyle:
|
|||||||
|
|
||||||
|
|
||||||
class TestStreaksText:
|
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))
|
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(
|
text = streaks_text(
|
||||||
{"2026-07-13": DayStatus.GREEN},
|
{"2026-07-13": DayStatus.GREEN},
|
||||||
today=date(2026, 7, 13),
|
today=date(2026, 7, 13),
|
||||||
)
|
)
|
||||||
assert "Logging streak: 1 day " in text
|
assert text == f"{g.FOOD} 1 {g.OK} 1"
|
||||||
assert "Adherence streak: 1 day" in text
|
|
||||||
|
|
||||||
|
|
||||||
class TestYtdText:
|
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:
|
def test_reports_logged_elapsed_and_adherent_counts(self) -> None:
|
||||||
status_map_ = {
|
status_map_ = {
|
||||||
@ -127,5 +127,4 @@ class TestYtdText:
|
|||||||
"2026-01-02": DayStatus.RED,
|
"2026-01-02": DayStatus.RED,
|
||||||
}
|
}
|
||||||
text = ytd_text(status_map_, today=date(2026, 1, 10))
|
text = ytd_text(status_map_, today=date(2026, 1, 10))
|
||||||
assert "logged 2/10 days" in text
|
assert text == f"{g.YEAR_TOTAL} {g.FOOD} 2/10 {g.OK} 1"
|
||||||
assert "1 within budget" in text
|
|
||||||
|
|||||||
191
diet_guard/tests/test_gate_no_words.py
Normal file
191
diet_guard/tests/test_gate_no_words.py
Normal file
@ -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") == []
|
||||||
74
diet_guard/tests/test_gate_no_words_static.py
Normal file
74
diet_guard/tests/test_gate_no_words_static.py
Normal file
@ -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
|
||||||
@ -19,6 +19,7 @@ from diet_guard import (
|
|||||||
_gatelock,
|
_gatelock,
|
||||||
_gatelock_support,
|
_gatelock_support,
|
||||||
)
|
)
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gatelock import (
|
from diet_guard._gatelock import (
|
||||||
MealGate,
|
MealGate,
|
||||||
_pending_slots,
|
_pending_slots,
|
||||||
@ -162,7 +163,7 @@ class TestLockDelegation:
|
|||||||
def test_callback_error_status(self, gate: MealGate) -> None:
|
def test_callback_error_status(self, gate: MealGate) -> None:
|
||||||
"""An unexpected callback error surfaces a recoverable message."""
|
"""An unexpected callback error surfaces a recoverable message."""
|
||||||
gate.on_callback_error()
|
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:
|
def test_run_delegates_to_lock(self, gate: MealGate) -> None:
|
||||||
"""run() hands off to the owned LockWindow."""
|
"""run() hands off to the owned LockWindow."""
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from diet_guard import _gatelock_body
|
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_fat_store import body_fats
|
||||||
from diet_guard._body_prefs import saved_goal
|
from diet_guard._body_prefs import saved_goal
|
||||||
from diet_guard._body_store import profile, set_profile, set_weight, weights
|
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:
|
def test_rejects_then_saves(self, tab: BodyTab) -> None:
|
||||||
tab.vars.profile.height.set("9")
|
tab.vars.profile.height.set("9")
|
||||||
tab.save_profile()
|
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.birth.set("2000-05-22")
|
||||||
tab.vars.profile.height.set("169")
|
tab.vars.profile.height.set("169")
|
||||||
tab.vars.profile.sex.set("m")
|
tab.vars.profile.sex.set("m")
|
||||||
tab.save_profile()
|
tab.save_profile()
|
||||||
assert profile().complete
|
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:
|
def test_complete_profile_loads_into_the_inputs(self) -> None:
|
||||||
_person()
|
_person()
|
||||||
with fake_tk():
|
with fake_tk():
|
||||||
body = BodyTab(FakeNotebook())
|
body = BodyTab(FakeNotebook())
|
||||||
assert body.vars.profile.height.get() == "169"
|
assert body.vars.profile.height.get() == "169"
|
||||||
assert body.vars.goal.answer.get().startswith("eat ")
|
assert body.vars.goal.answer.get().startswith("→ ")
|
||||||
|
|
||||||
|
|
||||||
class TestWeights:
|
class TestWeights:
|
||||||
@ -81,7 +82,7 @@ class TestWeights:
|
|||||||
write_budget(2000, weight_kg=90.0)
|
write_budget(2000, weight_kg=90.0)
|
||||||
tab.vars.logs.weight.set("5")
|
tab.vars.logs.weight.set("5")
|
||||||
tab.save_weight()
|
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.set("80")
|
||||||
tab.vars.logs.weight_day.set("2026-09-10")
|
tab.vars.logs.weight_day.set("2026-09-10")
|
||||||
tab.save_weight()
|
tab.save_weight()
|
||||||
@ -89,7 +90,7 @@ class TestWeights:
|
|||||||
assert (read_raw_record() or {})["w"] == 80.0
|
assert (read_raw_record() or {})["w"] == 80.0
|
||||||
tab.vars.logs.weight_day.set("nope")
|
tab.vars.logs.weight_day.set("nope")
|
||||||
tab.delete_weight()
|
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.vars.logs.weight_day.set("2026-09-10")
|
||||||
tab.delete_weight()
|
tab.delete_weight()
|
||||||
assert weights() == {}
|
assert weights() == {}
|
||||||
@ -97,13 +98,13 @@ class TestWeights:
|
|||||||
def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None:
|
def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None:
|
||||||
tab.vars.logs.body_fat.set("90")
|
tab.vars.logs.body_fat.set("90")
|
||||||
tab.save_body_fat()
|
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.vars.logs.body_fat.set("17")
|
||||||
tab.save_body_fat()
|
tab.save_body_fat()
|
||||||
assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0}
|
assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0}
|
||||||
tab.vars.logs.fat_day.set("x")
|
tab.vars.logs.fat_day.set("x")
|
||||||
tab.delete_body_fat()
|
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.vars.logs.fat_day.set(next(iter(body_fats())))
|
||||||
tab.delete_body_fat()
|
tab.delete_body_fat()
|
||||||
assert body_fats() == {}
|
assert body_fats() == {}
|
||||||
@ -118,7 +119,7 @@ class TestGoalAndGraph:
|
|||||||
assert saved_goal().weight_unit == "lb"
|
assert saved_goal().weight_unit == "lb"
|
||||||
assert saved_goal().amount == 0.5 # unreadable amount kept
|
assert saved_goal().amount == 0.5 # unreadable amount kept
|
||||||
assert tab.vars.goal.amount.get() == "0.5"
|
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:
|
def test_range_and_body_fat_graph(self, tab: BodyTab) -> None:
|
||||||
_person()
|
_person()
|
||||||
@ -127,7 +128,7 @@ class TestGoalAndGraph:
|
|||||||
tab.set_range(None)
|
tab.set_range(None)
|
||||||
tab.vars.logs.graph.set("fat")
|
tab.vars.logs.graph.set("fat")
|
||||||
tab.refresh()
|
tab.refresh()
|
||||||
assert "Mon" in tab.vars.blocks["plan"].get()
|
assert f"2500 {g.EDIT}" in tab.vars.blocks["plan"].get()
|
||||||
|
|
||||||
|
|
||||||
class TestGraph:
|
class TestGraph:
|
||||||
@ -150,6 +151,7 @@ class TestGraph:
|
|||||||
box = GraphBox(700, 300)
|
box = GraphBox(700, 300)
|
||||||
draw_weight_graph(canvas, {}, box, None)
|
draw_weight_graph(canvas, {}, box, None)
|
||||||
assert canvas.drawn[0][0] == "text"
|
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}
|
log = {"2026-09-20": 74.0, "2026-09-27": 72.0}
|
||||||
draw_weight_graph(canvas, log, box, (52.8, 71.1))
|
draw_weight_graph(canvas, log, box, (52.8, 71.1))
|
||||||
kinds = [kind for kind, _, _ in canvas.drawn]
|
kinds = [kind for kind, _, _ in canvas.drawn]
|
||||||
|
|||||||
166
diet_guard/tests/test_gatelock_body_glyphs.py
Normal file
166
diet_guard/tests/test_gatelock_body_glyphs.py
Normal file
@ -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
|
||||||
@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from diet_guard import _budget
|
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 import daily_budget, write_budget
|
||||||
from diet_guard._budget_derived import budget_weight
|
from diet_guard._budget_derived import budget_weight
|
||||||
from diet_guard._state import log_meal
|
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:
|
def test_builds_a_notebook_with_all_three_tabs(self, gate: MealGate) -> None:
|
||||||
labels = [text for _child, text in gate._notebook.tabs]
|
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:
|
def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None:
|
||||||
assert 1 <= gate._cal_month <= 12
|
assert 1 <= gate._cal_month <= 12
|
||||||
@ -64,8 +65,8 @@ class TestBudgetDefaultAndDegradation:
|
|||||||
gate: MealGate,
|
gate: MealGate,
|
||||||
) -> None:
|
) -> None:
|
||||||
gate._refresh_calendar()
|
gate._refresh_calendar()
|
||||||
assert "Logging streak" in gate._cal_vars.streaks.get()
|
assert gate._cal_vars.streaks.get().startswith(g.FOOD)
|
||||||
assert "This year" in gate._cal_vars.ytd.get()
|
assert gate._cal_vars.ytd.get().startswith(g.YEAR_TOTAL)
|
||||||
|
|
||||||
def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None:
|
def test_no_budget_set_shows_2200_in_the_entry(self, gate: MealGate) -> None:
|
||||||
gate._refresh_calendar()
|
gate._refresh_calendar()
|
||||||
@ -75,7 +76,7 @@ class TestBudgetDefaultAndDegradation:
|
|||||||
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
||||||
|
|
||||||
def test_edit_button_starts_labelled_edit(self, gate: MealGate) -> None:
|
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(
|
def test_no_budget_set_still_renders_every_grid_cell(
|
||||||
self,
|
self,
|
||||||
@ -90,7 +91,7 @@ class TestBudgetDefaultAndDegradation:
|
|||||||
log_meal("oatmeal", _nutrition(kcal=300), None)
|
log_meal("oatmeal", _nutrition(kcal=300), None)
|
||||||
gate._refresh_calendar()
|
gate._refresh_calendar()
|
||||||
assert gate._cal_vars.budget.get() == "1800"
|
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(
|
def test_a_corrupt_budget_file_degrades_with_an_error_message(
|
||||||
self,
|
self,
|
||||||
@ -98,7 +99,7 @@ class TestBudgetDefaultAndDegradation:
|
|||||||
) -> None:
|
) -> None:
|
||||||
_budget.BUDGET_FILE.write_text("not json")
|
_budget.BUDGET_FILE.write_text("not json")
|
||||||
gate._refresh_calendar()
|
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() == ""
|
assert gate._cal_vars.ytd.get() == ""
|
||||||
|
|
||||||
|
|
||||||
@ -144,7 +145,7 @@ class TestBudgetEditToggle:
|
|||||||
) -> None:
|
) -> None:
|
||||||
gate._on_edit_or_save_budget()
|
gate._on_edit_or_save_budget()
|
||||||
assert gate._cal_editing_budget is True
|
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"
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "normal"
|
||||||
|
|
||||||
def test_second_click_with_non_numeric_input_stays_in_edit_mode(
|
def test_second_click_with_non_numeric_input_stays_in_edit_mode(
|
||||||
@ -155,8 +156,8 @@ class TestBudgetEditToggle:
|
|||||||
self._type(gate, "not a number")
|
self._type(gate, "not a number")
|
||||||
gate._on_edit_or_save_budget()
|
gate._on_edit_or_save_budget()
|
||||||
assert gate._cal_editing_budget is True
|
assert gate._cal_editing_budget is True
|
||||||
assert "whole number" in gate._cal_vars.budget_status.get()
|
assert gate._cal_vars.budget_status.get() == f"{g.ERROR} kcal"
|
||||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Save"
|
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(
|
def test_second_click_with_zero_is_rejected_and_stays_in_edit_mode(
|
||||||
self,
|
self,
|
||||||
@ -166,13 +167,13 @@ class TestBudgetEditToggle:
|
|||||||
self._type(gate, "0")
|
self._type(gate, "0")
|
||||||
gate._on_edit_or_save_budget()
|
gate._on_edit_or_save_budget()
|
||||||
assert gate._cal_editing_budget is True
|
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:
|
def test_second_click_with_negative_is_rejected(self, gate: MealGate) -> None:
|
||||||
gate._on_edit_or_save_budget()
|
gate._on_edit_or_save_budget()
|
||||||
self._type(gate, "-500")
|
self._type(gate, "-500")
|
||||||
gate._on_edit_or_save_budget()
|
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(
|
def test_second_click_with_a_valid_value_saves_and_exits_edit_mode(
|
||||||
self,
|
self,
|
||||||
@ -182,8 +183,8 @@ class TestBudgetEditToggle:
|
|||||||
self._type(gate, "1900")
|
self._type(gate, "1900")
|
||||||
gate._on_edit_or_save_budget()
|
gate._on_edit_or_save_budget()
|
||||||
assert gate._cal_editing_budget is False
|
assert gate._cal_editing_budget is False
|
||||||
assert gate._cal_vars.budget_status.get() == "Saved."
|
assert gate._cal_vars.budget_status.get() == g.OK
|
||||||
assert gate._cal_widgets.budget_edit_button.configured.get("text") == "Edit"
|
assert gate._cal_widgets.budget_edit_button.configured.get("text") == g.EDIT
|
||||||
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
assert gate._cal_widgets.budget_entry.configured.get("state") == "readonly"
|
||||||
assert daily_budget() == 1900
|
assert daily_budget() == 1900
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, cast
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from diet_guard import _gatelock_delivery
|
from diet_guard import _gatelock_delivery
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._budget import write_budget
|
from diet_guard._budget import write_budget
|
||||||
from diet_guard._state import log_meal
|
from diet_guard._state import log_meal
|
||||||
from diet_guard.tests.conftest import _nutrition
|
from diet_guard.tests.conftest import _nutrition
|
||||||
@ -28,12 +29,12 @@ class TestDashboard:
|
|||||||
"""A sealed budget shows consumed/target/remaining."""
|
"""A sealed budget shows consumed/target/remaining."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
gate._refresh_dashboard()
|
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:
|
def test_headline_without_budget(self, gate: MealGate) -> None:
|
||||||
"""With no budget, only today's total is shown."""
|
"""With no budget, only today's total is shown."""
|
||||||
gate._refresh_dashboard()
|
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:
|
def test_dashboard_lists_entries(self, gate: MealGate) -> None:
|
||||||
"""Logged entries appear in the detail panel."""
|
"""Logged entries appear in the detail panel."""
|
||||||
@ -42,31 +43,31 @@ class TestDashboard:
|
|||||||
gate._refresh_dashboard()
|
gate._refresh_dashboard()
|
||||||
text = gate._vars.dashboard.get()
|
text = gate._vars.dashboard.get()
|
||||||
assert "apple" in text
|
assert "apple" in text
|
||||||
assert "protein" in text
|
assert f"{g.PROTEIN} 10 /" in text
|
||||||
|
|
||||||
def test_dashboard_empty(self, gate: MealGate) -> None:
|
def test_dashboard_empty(self, gate: MealGate) -> None:
|
||||||
"""With nothing logged, the panel says so."""
|
"""With nothing logged, the panel says so."""
|
||||||
gate._refresh_dashboard()
|
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:
|
def test_slot_header_variants(self, gate: MealGate) -> None:
|
||||||
"""The header covers none / one / several pending slots."""
|
"""The header covers none / one / several pending slots."""
|
||||||
gate._pending = []
|
gate._pending = []
|
||||||
gate._refresh_slot_header()
|
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._pending = [480]
|
||||||
gate._refresh_slot_header()
|
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._pending = [480, 720]
|
||||||
gate._refresh_slot_header()
|
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:
|
def test_projection_with_budget(self, gate: MealGate) -> None:
|
||||||
"""The projection shows the after-this-item remaining when priced."""
|
"""The projection shows the after-this-item remaining when priced."""
|
||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
gate._widgets.macros.kcal.insert(0, "300")
|
gate._widgets.macros.kcal.insert(0, "300")
|
||||||
gate._refresh_projection()
|
gate._refresh_projection()
|
||||||
assert "after this item" in gate._vars.projection.get()
|
assert gate._vars.projection.get().endswith(f"→ {g.BUDGET} 1700")
|
||||||
|
|
||||||
|
|
||||||
class TestSlotWalk:
|
class TestSlotWalk:
|
||||||
@ -121,7 +122,7 @@ class TestFetchFromSync:
|
|||||||
with patch.object(_gatelock_delivery, "pull_peer_logs") as pull:
|
with patch.object(_gatelock_delivery, "pull_peer_logs") as pull:
|
||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
pull.assert_not_called()
|
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:
|
def test_pull_failure_keeps_lock(self, gate: MealGate) -> None:
|
||||||
"""A failed pull shows the reason and leaves pending slots intact."""
|
"""A failed pull shows the reason and leaves pending slots intact."""
|
||||||
@ -135,7 +136,7 @@ class TestFetchFromSync:
|
|||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [480, 720]
|
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:
|
def test_no_new_meals_keeps_all_slots(self, gate: MealGate) -> None:
|
||||||
"""A clean pull that satisfies nothing reports so and keeps the slots."""
|
"""A clean pull that satisfies nothing reports so and keeps the slots."""
|
||||||
@ -148,7 +149,7 @@ class TestFetchFromSync:
|
|||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [480, 720]
|
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:
|
def test_partial_advances_to_next_slot(self, gate: MealGate) -> None:
|
||||||
"""One slot pulled in leaves the rest; the window advances (singular)."""
|
"""One slot pulled in leaves the rest; the window advances (singular)."""
|
||||||
@ -161,10 +162,10 @@ class TestFetchFromSync:
|
|||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [720]
|
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:
|
def test_partial_shows_the_count(self, gate: MealGate) -> None:
|
||||||
"""Two slots pulled in uses the plural 'meals'."""
|
"""Two slots pulled in shows the count 2."""
|
||||||
gate.demo_mode = False
|
gate.demo_mode = False
|
||||||
gate._pending = [480, 720, 960]
|
gate._pending = [480, 720, 960]
|
||||||
with (
|
with (
|
||||||
@ -174,7 +175,7 @@ class TestFetchFromSync:
|
|||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == [960]
|
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:
|
def test_all_satisfied_unlocks(self, gate: MealGate) -> None:
|
||||||
"""When the pull satisfies every pending slot, the gate unlocks."""
|
"""When the pull satisfies every pending slot, the gate unlocks."""
|
||||||
@ -187,7 +188,7 @@ class TestFetchFromSync:
|
|||||||
gate._on_fetch_sync()
|
gate._on_fetch_sync()
|
||||||
_drive_fetch(gate)
|
_drive_fetch(gate)
|
||||||
assert gate._pending == []
|
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:
|
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."""
|
"""A stray poll after completion must stop, not block on an empty queue."""
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gatelock import MealGate
|
from diet_guard._gatelock import MealGate
|
||||||
from diet_guard.tests._gate_fixtures import fake_tk
|
from diet_guard.tests._gate_fixtures import fake_tk
|
||||||
from diet_guard.tests.conftest import TWO_OUTPUTS
|
from diet_guard.tests.conftest import TWO_OUTPUTS
|
||||||
@ -100,7 +101,7 @@ class TestGateFansOut:
|
|||||||
|
|
||||||
for surface in dual_gate._cal_surfaces:
|
for surface in dual_gate._cal_surfaces:
|
||||||
assert surface.budget_entry.configured.get("state") == "normal"
|
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:
|
def test_frame_group_covers_every_monitor(self, dual_gate: MealGate) -> None:
|
||||||
"""The outer frame is exposed per surface, for tab selection."""
|
"""The outer frame is exposed per surface, for tab selection."""
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, cast
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from diet_guard import _gatelock_fillall
|
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_fetch import FETCH_POLL_MS
|
||||||
from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks
|
from diet_guard._gatelock_fillall import FILL_LABEL, FillAllFlow, FillHooks
|
||||||
from diet_guard._gatelock_kuchnia import DeliveryResult
|
from diet_guard._gatelock_kuchnia import DeliveryResult
|
||||||
@ -115,7 +116,7 @@ class TestFirstClick:
|
|||||||
with fetching() as start:
|
with fetching() as start:
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
start.assert_not_called()
|
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
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_a_click_while_fetching_is_ignored(self) -> None:
|
def test_a_click_while_fetching_is_ignored(self) -> None:
|
||||||
@ -127,7 +128,7 @@ class TestFirstClick:
|
|||||||
start.assert_called_once_with(
|
start.assert_called_once_with(
|
||||||
_gatelock_fillall._refresh_delivery, now_local().date()
|
_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)
|
h.root.after.assert_called_once_with(FETCH_POLL_MS, h.flow._poll)
|
||||||
|
|
||||||
def test_the_poll_rearms_while_the_worker_runs(self) -> None:
|
def test_the_poll_rearms_while_the_worker_runs(self) -> None:
|
||||||
@ -149,7 +150,7 @@ class TestFirstClick:
|
|||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(DeliveryResult(dishes=(), reason="panel down")):
|
with fetching(DeliveryResult(dishes=(), reason="panel down")):
|
||||||
propose(h)
|
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
|
assert h.label.get() == FILL_LABEL
|
||||||
# Fetch done: the next click may fetch again rather than being ignored.
|
# Fetch done: the next click may fetch again rather than being ignored.
|
||||||
assert h.flow._result is None
|
assert h.flow._result is None
|
||||||
@ -158,7 +159,7 @@ class TestFirstClick:
|
|||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered()):
|
with fetching(delivered()):
|
||||||
propose(h)
|
propose(h)
|
||||||
assert h.status == "No catering delivery today."
|
assert h.status == f"{g.EMPTY} {g.DELIVERY}"
|
||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_every_slot_already_logged(self) -> None:
|
def test_every_slot_already_logged(self) -> None:
|
||||||
@ -167,7 +168,7 @@ class TestFirstClick:
|
|||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
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
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
def test_the_proposal_writes_nothing(self) -> None:
|
def test_the_proposal_writes_nothing(self) -> None:
|
||||||
@ -175,10 +176,9 @@ class TestFirstClick:
|
|||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
propose(h)
|
||||||
assert h.label.get() == "✓ Confirm (3)"
|
assert h.label.get() == f"{g.OK} 3"
|
||||||
assert h.status == (
|
assert h.status == (
|
||||||
"Will log 3 (1144 kcal): 08:00 Owsianka, 16:00 Obiad, "
|
f"{g.DELIVERY} 3 1144 kcal 08:00 Owsianka 16:00 Obiad 20:00 Kolacja"
|
||||||
"20:00 Kolacja — click Confirm."
|
|
||||||
)
|
)
|
||||||
assert logged() == {720: ["my sandwich"]}
|
assert logged() == {720: ["my sandwich"]}
|
||||||
h.on_logged.assert_not_called()
|
h.on_logged.assert_not_called()
|
||||||
@ -198,7 +198,7 @@ class TestConfirm:
|
|||||||
960: ["Obiad"],
|
960: ["Obiad"],
|
||||||
1200: ["Kolacja"],
|
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()
|
h.on_logged.assert_called_once_with()
|
||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
|
|
||||||
@ -210,17 +210,17 @@ class TestConfirm:
|
|||||||
propose(h)
|
propose(h)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert logged()[480] == ["Owsianka", "Wrap"]
|
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:
|
def test_a_meal_typed_between_the_clicks_keeps_its_slot(self) -> None:
|
||||||
h = Harness()
|
h = Harness()
|
||||||
with fetching(delivered(*FOUR)):
|
with fetching(delivered(*FOUR)):
|
||||||
propose(h)
|
propose(h)
|
||||||
assert h.label.get() == "✓ Confirm (4)"
|
assert h.label.get() == f"{g.OK} 4"
|
||||||
log_meal("toast", _nutrition(), 480)
|
log_meal("toast", _nutrition(), 480)
|
||||||
h.flow.click()
|
h.flow.click()
|
||||||
assert logged()[480] == ["toast"]
|
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:
|
def test_every_slot_taken_between_the_clicks(self) -> None:
|
||||||
h = Harness()
|
h = Harness()
|
||||||
@ -229,7 +229,7 @@ class TestConfirm:
|
|||||||
for slot in (480, 720, 960, 1200):
|
for slot in (480, 720, 960, 1200):
|
||||||
log_meal(f"mine {slot}", _nutrition(), slot)
|
log_meal(f"mine {slot}", _nutrition(), slot)
|
||||||
h.flow.click()
|
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()
|
h.on_logged.assert_not_called()
|
||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
assert all(descs == [f"mine {s}"] for s, descs in logged().items())
|
assert all(descs == [f"mine {s}"] for s, descs in logged().items())
|
||||||
@ -247,4 +247,4 @@ class TestConfirm:
|
|||||||
assert logged() == {}
|
assert logged() == {}
|
||||||
h.on_logged.assert_not_called()
|
h.on_logged.assert_not_called()
|
||||||
assert h.label.get() == FILL_LABEL
|
assert h.label.get() == FILL_LABEL
|
||||||
assert h.status == "Loading today's delivery…"
|
assert h.status == f"{g.BUSY} {g.DELIVERY}"
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from diet_guard import _gatelock_delivery, _gatelock_fillall, _gatelock_layout
|
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 import MealGate
|
||||||
from diet_guard._gatelock_fillall import FILL_LABEL, _refresh_delivery
|
from diet_guard._gatelock_fillall import FILL_LABEL, _refresh_delivery
|
||||||
from diet_guard._gatelock_layout import build_layout
|
from diet_guard._gatelock_layout import build_layout
|
||||||
@ -82,7 +83,7 @@ class TestTheGateDrivesTheFlow:
|
|||||||
with patch.object(_gatelock_fillall, "start_delivery_fetch") as start:
|
with patch.object(_gatelock_fillall, "start_delivery_fetch") as start:
|
||||||
gate._on_fill_all()
|
gate._on_fill_all()
|
||||||
start.assert_not_called()
|
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:
|
def test_fill_and_confirm_unlocks_from_catering(self, gate: MealGate) -> None:
|
||||||
gate.demo_mode = False # read once, when the flow is first built
|
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):
|
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
||||||
_fill(gate, delivered(*FOUR))
|
_fill(gate, delivered(*FOUR))
|
||||||
status = gate._vars.status.get()
|
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 == []
|
assert gate._pending == []
|
||||||
gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close)
|
gate.root.after.assert_called_with(_UNLOCK_DELAY_MS, gate.close)
|
||||||
assert sorted(logged()) == [480, 720, 960, 1200]
|
assert sorted(logged()) == [480, 720, 960, 1200]
|
||||||
@ -109,8 +110,8 @@ class TestTheGateDrivesTheFlow:
|
|||||||
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
with patch.object(_gatelock_delivery, "due_slots", side_effect=due):
|
||||||
_fill(gate, delivered(*two))
|
_fill(gate, delivered(*two))
|
||||||
assert gate._pending == [720]
|
assert gate._pending == [720]
|
||||||
assert "Pulled 1 meal " in gate._vars.status.get()
|
assert g.UNLOCKING not in gate._vars.status.get()
|
||||||
assert "unlocking" not in gate._vars.status.get()
|
assert gate._vars.status.get().startswith(g.OK)
|
||||||
|
|
||||||
|
|
||||||
class TestLeftoverDishesAtUnlock:
|
class TestLeftoverDishesAtUnlock:
|
||||||
@ -120,22 +121,21 @@ class TestLeftoverDishesAtUnlock:
|
|||||||
# Case and padding differ from the dish name on purpose.
|
# Case and padding differ from the dish name on purpose.
|
||||||
log_meal(" KOLACJA ", _nutrition(), 20)
|
log_meal(" KOLACJA ", _nutrition(), 20)
|
||||||
gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3))
|
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() == (
|
assert gate._vars.status.get() == (
|
||||||
"Logged 20:00 (1 more dish delivered; log with 'ate') — "
|
f"{g.OK} 20:00 {g.DELIVERY} +1 {g.UNLOCKING}"
|
||||||
"all meals logged, unlocking…"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unlogged_dishes_are_all_counted(self, gate: MealGate) -> None:
|
def test_unlogged_dishes_are_all_counted(self, gate: MealGate) -> None:
|
||||||
gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3))
|
gate._delivery_pending = (dish("Kolacja", 4), dish("Obiad", 3))
|
||||||
gate._unlock("Logged 20:00")
|
gate._unlock(f"{g.OK} 20:00")
|
||||||
assert "(2 more dishes delivered;" in gate._vars.status.get()
|
assert f"{g.DELIVERY} +2" in gate._vars.status.get()
|
||||||
|
|
||||||
def test_every_queued_dish_logged_means_no_suffix(self, gate: MealGate) -> None:
|
def test_every_queued_dish_logged_means_no_suffix(self, gate: MealGate) -> None:
|
||||||
log_meal("Kolacja", _nutrition(), 20)
|
log_meal("Kolacja", _nutrition(), 20)
|
||||||
gate._delivery_pending = (dish("Kolacja", 4),)
|
gate._delivery_pending = (dish("Kolacja", 4),)
|
||||||
gate._unlock("Logged 20:00")
|
gate._unlock(f"{g.OK} 20:00")
|
||||||
assert "more dish" not in gate._vars.status.get()
|
assert g.DELIVERY not in gate._vars.status.get()
|
||||||
|
|
||||||
|
|
||||||
class TestTheButton:
|
class TestTheButton:
|
||||||
@ -188,8 +188,8 @@ class TestTheButton:
|
|||||||
widgets = build_layout(notebook, vars_, callbacks, demo_mode=False)
|
widgets = build_layout(notebook, vars_, callbacks, demo_mode=False)
|
||||||
button = _find_fill_button(widgets.frame, str(vars_.fill_label))
|
button = _find_fill_button(widgets.frame, str(vars_.fill_label))
|
||||||
assert button.cget("text") == FILL_LABEL
|
assert button.cget("text") == FILL_LABEL
|
||||||
vars_.fill_label.set("✓ Confirm (3)")
|
vars_.fill_label.set(f"{g.OK} 3")
|
||||||
assert button.cget("text") == "✓ Confirm (3)"
|
assert button.cget("text") == f"{g.OK} 3"
|
||||||
button.invoke()
|
button.invoke()
|
||||||
on_fill_all.assert_called_once_with()
|
on_fill_all.assert_called_once_with()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard import (
|
from diet_guard import (
|
||||||
_gatelock_ui,
|
_gatelock_ui,
|
||||||
)
|
)
|
||||||
@ -107,8 +108,8 @@ class TestBasisAndAmount:
|
|||||||
write_budget(2000)
|
write_budget(2000)
|
||||||
gate._refresh_projection()
|
gate._refresh_projection()
|
||||||
text = gate._vars.projection.get()
|
text = gate._vars.projection.get()
|
||||||
assert "left" in text
|
assert f"{g.BUDGET} 2000" in text
|
||||||
assert "after this item" not in text
|
assert "→" not in text
|
||||||
|
|
||||||
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
def test_keyrelease_grams_mode(self, gate: MealGate) -> None:
|
||||||
"""In grams mode the per-item weight is not touched on keyrelease."""
|
"""In grams mode the per-item weight is not touched on keyrelease."""
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard import _gatelock_mealflow
|
from diet_guard import _gatelock_mealflow
|
||||||
from diet_guard.tests.conftest import _nutrition
|
from diet_guard.tests.conftest import _nutrition
|
||||||
|
|
||||||
@ -116,14 +117,14 @@ class TestSubmit:
|
|||||||
def test_empty_description(self, gate: MealGate) -> None:
|
def test_empty_description(self, gate: MealGate) -> None:
|
||||||
"""Submitting with no description prompts for one."""
|
"""Submitting with no description prompts for one."""
|
||||||
gate._on_submit()
|
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:
|
def test_non_numeric_macros(self, gate: MealGate) -> None:
|
||||||
"""Non-numeric macros are rejected before logging."""
|
"""Non-numeric macros are rejected before logging."""
|
||||||
gate._set_desc("apple")
|
gate._set_desc("apple")
|
||||||
gate._widgets.macros.kcal.insert(0, "abc")
|
gate._widgets.macros.kcal.insert(0, "abc")
|
||||||
gate._on_submit()
|
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:
|
def test_blank_calories_triggers_lookup(self, gate: MealGate) -> None:
|
||||||
"""A blank calorie field looks the food up rather than logging."""
|
"""A blank calorie field looks the food up rather than logging."""
|
||||||
@ -138,7 +139,7 @@ class TestSubmit:
|
|||||||
gate._widgets.macros.kcal.insert(0, "200")
|
gate._widgets.macros.kcal.insert(0, "200")
|
||||||
with patch.object(gate, "_current_nutrition", return_value=None):
|
with patch.object(gate, "_current_nutrition", return_value=None):
|
||||||
gate._on_submit()
|
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:
|
def test_valid_submit_records(self, gate: MealGate) -> None:
|
||||||
"""A described, priced meal is recorded."""
|
"""A described, priced meal is recorded."""
|
||||||
@ -163,7 +164,7 @@ class TestLookup:
|
|||||||
gate._set_desc("nonsense")
|
gate._set_desc("nonsense")
|
||||||
with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]):
|
with patch.object(_gatelock_mealflow, "lookup_candidates", return_value=[]):
|
||||||
gate._begin_lookup("nonsense")
|
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:
|
def test_single_candidate(self, gate: MealGate) -> None:
|
||||||
"""A single match fills the fields and invites review."""
|
"""A single match fills the fields and invites review."""
|
||||||
@ -173,7 +174,8 @@ class TestLookup:
|
|||||||
return_value=[("apple", _nutrition(95, 100))],
|
return_value=[("apple", _nutrition(95, 100))],
|
||||||
):
|
):
|
||||||
gate._begin_lookup("apple")
|
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:
|
def test_multiple_candidates(self, gate: MealGate) -> None:
|
||||||
"""Several matches invite picking another."""
|
"""Several matches invite picking another."""
|
||||||
@ -186,7 +188,7 @@ class TestLookup:
|
|||||||
],
|
],
|
||||||
):
|
):
|
||||||
gate._begin_lookup("apple")
|
gate._begin_lookup("apple")
|
||||||
assert "pick another" in gate._vars.status.get()
|
assert gate._vars.status.get().endswith(f"{g.TIMES}2")
|
||||||
|
|
||||||
|
|
||||||
class TestRecord:
|
class TestRecord:
|
||||||
@ -219,9 +221,9 @@ class TestRecord:
|
|||||||
):
|
):
|
||||||
gate._record("apple", _nutrition(95, 100))
|
gate._record("apple", _nutrition(95, 100))
|
||||||
assert gate._pending == [720]
|
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:
|
def test_unlock_schedules_close(self, gate: MealGate) -> None:
|
||||||
"""Unlock sets the closing status and schedules teardown."""
|
"""Unlock sets the closing status and schedules teardown."""
|
||||||
gate._unlock("logged X")
|
gate._unlock("logged X")
|
||||||
assert "unlocking" in gate._vars.status.get()
|
assert g.UNLOCKING in gate._vars.status.get()
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary
|
from diet_guard._gatelock_scheduleedit import schedule_problem, schedule_summary
|
||||||
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
|
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
|
||||||
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
|
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 not gate._cal_editing_schedule
|
||||||
assert current_schedule() == MealSchedule(435, 1140, 5)
|
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")
|
assert gate._cal_vars.schedule.times.get().startswith("07:15 10:15")
|
||||||
|
|
||||||
def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None:
|
def test_a_bare_hour_is_still_accepted(self, gate: MealGate) -> None:
|
||||||
@ -106,22 +107,22 @@ class TestValidation:
|
|||||||
_type(gate, first, "20:00", "4")
|
_type(gate, first, "20:00", "4")
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
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:
|
def test_rejects_a_non_numeric_count(self, gate: MealGate) -> None:
|
||||||
_type(gate, "08:00", "20:00", "five")
|
_type(gate, "08:00", "20:00", "five")
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
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(
|
@pytest.mark.parametrize(
|
||||||
("first", "last", "count", "reason"),
|
("first", "last", "count", "reason"),
|
||||||
[
|
[
|
||||||
("20:00", "08:00", "4", "at least 15 min after"),
|
("20:00", "08:00", "4", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"),
|
||||||
("08:00", "08:14", "2", "at least 15 min after"),
|
("08:00", "08:14", "2", f"{g.ERROR} {g.CLOCK}{g.DASH}{g.CLOCK} ≥ 15 min"),
|
||||||
("08:00", "20:00", "1", "Meals per day must be 2-6."),
|
("08:00", "20:00", "1", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"),
|
||||||
("08:00", "20:00", "9", "Meals per day must be 2-6."),
|
("08:00", "20:00", "9", f"{g.ERROR} {g.TIMES} 2{g.DASH}6"),
|
||||||
("08:00", "08:45", "5", "Too many meals"),
|
("08:00", "08:45", "5", f"{g.ERROR} {g.TIMES} ≤ 4"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_rejects_what_normalize_would_silently_change(
|
def test_rejects_what_normalize_would_silently_change(
|
||||||
@ -130,7 +131,7 @@ class TestValidation:
|
|||||||
_type(gate, first, last, count)
|
_type(gate, first, last, count)
|
||||||
|
|
||||||
assert not gate._save_schedule_entry()
|
assert not gate._save_schedule_entry()
|
||||||
assert reason in _status(gate)
|
assert reason == _status(gate)
|
||||||
assert current_schedule() == DEFAULT_SCHEDULE
|
assert current_schedule() == DEFAULT_SCHEDULE
|
||||||
|
|
||||||
def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None:
|
def test_the_narrowest_legal_window_is_accepted(self, gate: MealGate) -> None:
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import queue
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from diet_guard import _gatelock_delivery
|
from diet_guard import _gatelock_delivery
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard._gatelock_kuchnia import DeliveryResult
|
from diet_guard._gatelock_kuchnia import DeliveryResult
|
||||||
from diet_guard.tests.test_kuchnia_gate import _deliver, _dish, _Gate
|
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:
|
def test_the_button_still_reports_both(self) -> None:
|
||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
_deliver(gate, DeliveryResult(dishes=(), reason="panel down"), asked=True)
|
_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}"
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from diet_guard import _gatelock_delivery
|
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_delivery import _PullFlows
|
||||||
from diet_guard._gatelock_kuchnia import (
|
from diet_guard._gatelock_kuchnia import (
|
||||||
UNEXPECTED,
|
UNEXPECTED,
|
||||||
@ -131,7 +132,7 @@ class TestLoadButton:
|
|||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
gate.demo_mode = True
|
gate.demo_mode = True
|
||||||
gate._on_load_delivery()
|
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
|
assert gate._delivery_result is None
|
||||||
|
|
||||||
def test_a_second_click_while_in_flight_is_ignored(self) -> None:
|
def test_a_second_click_while_in_flight_is_ignored(self) -> None:
|
||||||
@ -146,15 +147,14 @@ class TestLoadButton:
|
|||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
_deliver(gate, DeliveryResult(dishes=(), reason="panel down"))
|
_deliver(gate, DeliveryResult(dishes=(), reason="panel down"))
|
||||||
text, error = gate.statuses[-1]
|
text, error = gate.statuses[-1]
|
||||||
assert "panel down" in text
|
assert text == f"{g.ERROR} {g.DELIVERY}"
|
||||||
assert "still locked" in text
|
|
||||||
assert error is True
|
assert error is True
|
||||||
assert gate._descs == []
|
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()
|
gate = _Gate()
|
||||||
_deliver(gate, DeliveryResult(dishes=(), reason=None))
|
_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:
|
def test_a_dish_is_offered_but_never_logged(self) -> None:
|
||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
@ -165,21 +165,21 @@ class TestLoadButton:
|
|||||||
assert gate._references == [dish_nutrition(_dish())]
|
assert gate._references == [dish_nutrition(_dish())]
|
||||||
assert gate._references[0].grams == 318.0
|
assert gate._references[0].grams == 318.0
|
||||||
# ...and that is all. Submitting stays the user's explicit action.
|
# ...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:
|
def test_dishes_are_offered_one_at_a_time_in_meal_order(self) -> None:
|
||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1))
|
dishes = (_dish("Kolacja", priority=2), _dish("Sniadanie", priority=1))
|
||||||
_deliver(gate, DeliveryResult(dishes=dishes, reason=None))
|
_deliver(gate, DeliveryResult(dishes=dishes, reason=None))
|
||||||
assert gate._descs == ["Sniadanie"]
|
assert gate._descs == ["Sniadanie"]
|
||||||
assert "1 more to go" in gate.last_status
|
assert gate.last_status.endswith("+1")
|
||||||
gate._prefill_next_dish()
|
gate._prefill_next_dish()
|
||||||
assert gate._descs == ["Sniadanie", "Kolacja"]
|
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()
|
gate = _Gate()
|
||||||
_deliver(gate, DeliveryResult(dishes=(_dish(),), reason=None))
|
_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:
|
def test_prefilling_past_the_end_is_a_no_op(self) -> None:
|
||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import queue
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from diet_guard import _gatelock_glyphs as g
|
||||||
from diet_guard import _gatelock_mealflow
|
from diet_guard import _gatelock_mealflow
|
||||||
from diet_guard._gatelock_kuchnia import DeliveryResult
|
from diet_guard._gatelock_kuchnia import DeliveryResult
|
||||||
from diet_guard._kuchnia_log import dish_nutrition
|
from diet_guard._kuchnia_log import dish_nutrition
|
||||||
@ -60,16 +61,17 @@ class TestTheQueueSurvivesASubmit:
|
|||||||
reason=None,
|
reason=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
gate._prefill_next_dish("Logged 08:00: 391 kcal")
|
logged = f"{g.OK} 08:00 391 kcal"
|
||||||
assert "Logged 08:00: 391 kcal" in gate.last_status
|
gate._prefill_next_dish(logged)
|
||||||
assert "Loaded: Second" in gate.last_status
|
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:
|
def test_prefilling_without_a_log_line_stays_unprefixed(self) -> None:
|
||||||
# The button and the autoload pass no ``logged``, so the status must
|
# The button and the autoload pass no ``logged``, so the status must
|
||||||
# not grow a stray leading separator.
|
# not grow a stray leading separator.
|
||||||
gate = _Gate()
|
gate = _Gate()
|
||||||
_deliver(gate, DeliveryResult(dishes=(_dish("Only"),), reason=None))
|
_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:
|
def test_an_exhausted_queue_prefills_nothing(self) -> None:
|
||||||
# The guard that lets ``_finish_slot`` call this unconditionally.
|
# The guard that lets ``_finish_slot`` call this unconditionally.
|
||||||
@ -143,8 +145,8 @@ class TestTheGateAdvancesTheQueue:
|
|||||||
):
|
):
|
||||||
gate._record("apple", _nutrition(95, 100))
|
gate._record("apple", _nutrition(95, 100))
|
||||||
assert not gate._delivery_pending
|
assert not gate._delivery_pending
|
||||||
assert "Loaded: Second course" in gate._vars.status.get()
|
assert f"{g.DELIVERY} Second course" in gate._vars.status.get()
|
||||||
assert "Logged 08:00" 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:
|
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.
|
"""What the submit logs is the dish, not the dish scaled by grams/100.
|
||||||
@ -176,5 +178,5 @@ class TestTheGateAdvancesTheQueue:
|
|||||||
was never offered.
|
was never offered.
|
||||||
"""
|
"""
|
||||||
gate._delivery_pending = (_dish("Kolacja"),)
|
gate._delivery_pending = (_dish("Kolacja"),)
|
||||||
gate._unlock("Logged 20:00: 391 kcal")
|
gate._unlock(f"{g.OK} 20:00 391 kcal")
|
||||||
assert "1 more dish delivered" in gate._vars.status.get()
|
assert f"{g.DELIVERY} +1" in gate._vars.status.get()
|
||||||
|
|||||||
70
diet_guard/tests/test_textgate_scan_clean.py
Normal file
70
diet_guard/tests/test_textgate_scan_clean.py
Normal file
@ -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"))
|
||||||
56
docs/DOCS-textgate-keep.md
Normal file
56
docs/DOCS-textgate-keep.md
Normal file
@ -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`.
|
||||||
Loading…
Reference in New Issue
Block a user