From 3e51e614b1df92b1da73b2c81193014b7e7c2023 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 20:37:05 +0200 Subject: [PATCH] Split the last seven Python files; extend the redirect guard to all five paths Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC --- app/pubspec.yaml | 2 +- diet_guard/_averages.py | 128 ------------ diet_guard/_averages_periods.py | 76 +++++++ diet_guard/_averages_report.py | 155 +++++++++++++++ diet_guard/_budget.py | 97 --------- diet_guard/_budget_biometrics.py | 80 ++++++++ diet_guard/_budget_derived.py | 51 +++++ diet_guard/_calendar_view.py | 8 +- diet_guard/_cli.py | 2 +- diet_guard/_cli_averages.py | 7 +- diet_guard/_cli_init.py | 3 +- diet_guard/_estimator.py | 188 ------------------ diet_guard/_estimator_off.py | 214 ++++++++++++++++++++ diet_guard/_foodbank.py | 158 --------------- diet_guard/_foodbank_rebuild.py | 85 ++++++++ diet_guard/_foodbank_search.py | 118 +++++++++++ diet_guard/_gatelock_budgetedit.py | 3 +- diet_guard/_gatelock_dashboard.py | 3 +- diet_guard/_mcp_read.py | 8 +- diet_guard/_resolve.py | 11 +- diet_guard/_sync.py | 2 +- diet_guard/tests/test_averages.py | 157 ++------------- diet_guard/tests/test_averages_report.py | 163 +++++++++++++++ diet_guard/tests/test_budget.py | 11 +- diet_guard/tests/test_budget_history.py | 134 +------------ diet_guard/tests/test_budget_history_io.py | 152 ++++++++++++++ diet_guard/tests/test_estimator.py | 16 +- diet_guard/tests/test_foodbank.py | 200 +------------------ diet_guard/tests/test_foodbank_manual.py | 7 +- diet_guard/tests/test_foodbank_rebuild.py | 219 +++++++++++++++++++++ diet_guard/tests/test_gatelock_calendar.py | 3 +- diet_guard/tests/test_state_redirect.py | 91 ++++++--- diet_guard/tests/test_sync.py | 2 +- diet_guard/tests/test_sync_banks.py | 3 +- diet_guard/tests/test_sync_budget.py | 3 +- 35 files changed, 1434 insertions(+), 1126 deletions(-) create mode 100644 diet_guard/_averages_periods.py create mode 100644 diet_guard/_averages_report.py create mode 100644 diet_guard/_budget_biometrics.py create mode 100644 diet_guard/_budget_derived.py create mode 100644 diet_guard/_estimator_off.py create mode 100644 diet_guard/_foodbank_rebuild.py create mode 100644 diet_guard/_foodbank_search.py create mode 100644 diet_guard/tests/test_averages_report.py create mode 100644 diet_guard/tests/test_budget_history_io.py create mode 100644 diet_guard/tests/test_foodbank_rebuild.py diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bb5a4b0..da0a977 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.22+1 +version: 1.0.23+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_averages.py b/diet_guard/_averages.py index ecd96e3..40622ba 100644 --- a/diet_guard/_averages.py +++ b/diet_guard/_averages.py @@ -38,14 +38,12 @@ boundaries and the period edges are testable with synthetic data. from __future__ import annotations -import calendar from dataclasses import dataclass from datetime import date, timedelta import enum from typing import TYPE_CHECKING from diet_guard._daystatus import OVER_BUDGET_YELLOW_CEILING, day_total_kcal -from diet_guard._state import now_local if TYPE_CHECKING: from diet_guard._budget_history import BudgetSchedule @@ -118,30 +116,6 @@ def band_for(avg_kcal: float, avg_budget: float) -> AverageBand: return AverageBand.VERY_OVER -def last_complete_day(today: date) -> date: - """Return the last day whose log is finished: the day before ``today``.""" - return today - timedelta(days=1) - - -def week_bounds(day: date) -> tuple[date, date]: - """Return the Monday and Sunday of the ISO week containing ``day``.""" - monday = day - timedelta(days=day.weekday()) - return monday, monday + timedelta(days=_DAYS_PER_WEEK - 1) - - -def month_bounds(day: date) -> tuple[date, date]: - """Return the first and last dates of ``day``'s calendar month.""" - last = calendar.monthrange(day.year, day.month)[1] - return day.replace(day=1), day.replace(day=last) - - -def _shift_months(day: date, months: int) -> date: - """Return the first of the month ``months`` before ``day``'s month.""" - index = day.year * _MONTHS_PER_YEAR + (day.month - 1) - months - year, month = divmod(index, _MONTHS_PER_YEAR) - return date(year, month + 1, 1) - - def period_average( log: DayLog, *, @@ -194,105 +168,3 @@ def period_average( avg_budget=avg_budget, band=band_for(avg_kcal, avg_budget), ) - - -def _resolve_today(today: date | None) -> date: - """Return ``today`` or the real current local date when it is None.""" - return today if today is not None else now_local().date() - - -def _capped( - log: DayLog, - schedule: BudgetSchedule, - bounds: tuple[date, date], - ref: date, -) -> PeriodAverage: - """Average ``bounds``, truncated so it never includes ``ref`` itself. - - The single place the "today is excluded" rule is applied, so a caller - cannot construct a period that half-counts an unfinished day. - """ - start, end = bounds - return period_average( - log, - schedule=schedule, - start=start, - end=min(end, last_complete_day(ref)), - ) - - -def weekly_average( - log: DayLog, - *, - schedule: BudgetSchedule, - weeks_ago: int = 0, - today: date | None = None, -) -> PeriodAverage: - """Return the average for an ISO week, ``weeks_ago`` weeks back. - - Args: - log: A filtered log (see :func:`period_average`). - schedule: Resolves each day's budget. - weeks_ago: 0 for the current week (through yesterday), 1 for the - previous complete week, and so on. - today: The reference "today"; defaults to the real current date. - - Returns: - That week's :class:`PeriodAverage`. - """ - ref = _resolve_today(today) - anchor = ref - timedelta(weeks=weeks_ago) - return _capped(log, schedule, week_bounds(anchor), ref) - - -def monthly_average( - log: DayLog, - *, - schedule: BudgetSchedule, - months_ago: int = 0, - today: date | None = None, -) -> PeriodAverage: - """Return the average for a calendar month, ``months_ago`` months back. - - Args: - log: A filtered log (see :func:`period_average`). - schedule: Resolves each day's budget. - months_ago: 0 for the current month (through yesterday), 1 for the - previous complete month, and so on. - today: The reference "today"; defaults to the real current date. - - Returns: - That month's :class:`PeriodAverage`. - """ - ref = _resolve_today(today) - return _capped(log, schedule, month_bounds(_shift_months(ref, months_ago)), ref) - - -def recent_weeks( - log: DayLog, - *, - schedule: BudgetSchedule, - count: int, - today: date | None = None, -) -> tuple[PeriodAverage, ...]: - """Return the last ``count`` weekly averages, most recent first.""" - ref = _resolve_today(today) - return tuple( - weekly_average(log, schedule=schedule, weeks_ago=back, today=ref) - for back in range(count) - ) - - -def recent_months( - log: DayLog, - *, - schedule: BudgetSchedule, - count: int, - today: date | None = None, -) -> tuple[PeriodAverage, ...]: - """Return the last ``count`` monthly averages, most recent first.""" - ref = _resolve_today(today) - return tuple( - monthly_average(log, schedule=schedule, months_ago=back, today=ref) - for back in range(count) - ) diff --git a/diet_guard/_averages_periods.py b/diet_guard/_averages_periods.py new file mode 100644 index 0000000..a029b46 --- /dev/null +++ b/diet_guard/_averages_periods.py @@ -0,0 +1,76 @@ +"""Weekly and monthly average intake, and how that average met the budget. + +PERIOD AVERAGE SPEC (keep in sync with +``app/lib/services/average_service.dart``): + +* the average is over **logged days only** -- ``sum(kcal) / logged_days``. A + day you forgot to log is not a zero-calorie day, and counting it as one + would make every gap in the log read as "well under budget", which is the + exact opposite of the truth. ``logged_days`` / ``elapsed_days`` travel with + the average so a caller can show how much of the period it actually covers. +* the yardstick is the **mean of the per-day budgets over those same logged + days**, resolved through a + :class:`~diet_guard._budget_history.BudgetSchedule` -- never today's budget. + Comparing a past week's intake against a budget you lowered yesterday would + retroactively reclassify it, which is precisely what + :mod:`diet_guard._budget_history` exists to prevent. +* bands, reusing the calendar's own over-budget boundary + (:data:`~diet_guard._daystatus.OVER_BUDGET_YELLOW_CEILING`) so a period and + its days can never disagree about what "over" means: + + - ``under``: ``avg_kcal <= avg_budget`` + - ``slightly_over``: ``avg_budget < avg_kcal <= avg_budget * ceiling`` + - ``very_over``: ``avg_kcal > avg_budget * ceiling`` + +* **today is excluded.** A period ending at "now" mixes complete days with + one that is three hours old, and a half-logged today drags the mean down far + enough to flip the band. Every period therefore ends at + :func:`last_complete_day` -- yesterday -- so "this week" means "this week so + far, in finished days". A period with no finished days yet (Monday, or the + 1st) reports ``elapsed_days == 0`` and a ``None`` average rather than a + flattering fake one. +* weeks are **ISO weeks, Monday through Sunday**; months are calendar months. + +Every function here is a pure function of its explicit ``log`` / ``schedule`` +/ ``today`` arguments and never reaches into on-disk state, so the band +boundaries and the period edges are testable with synthetic data. +""" + +from __future__ import annotations + +import calendar +from datetime import date, timedelta + +from diet_guard._state import now_local + +_DAYS_PER_WEEK = 7 +_MONTHS_PER_YEAR = 12 + + +def last_complete_day(today: date) -> date: + """Return the last day whose log is finished: the day before ``today``.""" + return today - timedelta(days=1) + + +def week_bounds(day: date) -> tuple[date, date]: + """Return the Monday and Sunday of the ISO week containing ``day``.""" + monday = day - timedelta(days=day.weekday()) + return monday, monday + timedelta(days=_DAYS_PER_WEEK - 1) + + +def month_bounds(day: date) -> tuple[date, date]: + """Return the first and last dates of ``day``'s calendar month.""" + last = calendar.monthrange(day.year, day.month)[1] + return day.replace(day=1), day.replace(day=last) + + +def _shift_months(day: date, months: int) -> date: + """Return the first of the month ``months`` before ``day``'s month.""" + index = day.year * _MONTHS_PER_YEAR + (day.month - 1) - months + year, month = divmod(index, _MONTHS_PER_YEAR) + return date(year, month + 1, 1) + + +def _resolve_today(today: date | None) -> date: + """Return ``today`` or the real current local date when it is None.""" + return today if today is not None else now_local().date() diff --git a/diet_guard/_averages_report.py b/diet_guard/_averages_report.py new file mode 100644 index 0000000..30be852 --- /dev/null +++ b/diet_guard/_averages_report.py @@ -0,0 +1,155 @@ +"""Weekly and monthly average intake, and how that average met the budget. + +PERIOD AVERAGE SPEC (keep in sync with +``app/lib/services/average_service.dart``): + +* the average is over **logged days only** -- ``sum(kcal) / logged_days``. A + day you forgot to log is not a zero-calorie day, and counting it as one + would make every gap in the log read as "well under budget", which is the + exact opposite of the truth. ``logged_days`` / ``elapsed_days`` travel with + the average so a caller can show how much of the period it actually covers. +* the yardstick is the **mean of the per-day budgets over those same logged + days**, resolved through a + :class:`~diet_guard._budget_history.BudgetSchedule` -- never today's budget. + Comparing a past week's intake against a budget you lowered yesterday would + retroactively reclassify it, which is precisely what + :mod:`diet_guard._budget_history` exists to prevent. +* bands, reusing the calendar's own over-budget boundary + (:data:`~diet_guard._daystatus.OVER_BUDGET_YELLOW_CEILING`) so a period and + its days can never disagree about what "over" means: + + - ``under``: ``avg_kcal <= avg_budget`` + - ``slightly_over``: ``avg_budget < avg_kcal <= avg_budget * ceiling`` + - ``very_over``: ``avg_kcal > avg_budget * ceiling`` + +* **today is excluded.** A period ending at "now" mixes complete days with + one that is three hours old, and a half-logged today drags the mean down far + enough to flip the band. Every period therefore ends at + :func:`last_complete_day` -- yesterday -- so "this week" means "this week so + far, in finished days". A period with no finished days yet (Monday, or the + 1st) reports ``elapsed_days == 0`` and a ``None`` average rather than a + flattering fake one. +* weeks are **ISO weeks, Monday through Sunday**; months are calendar months. + +Every function here is a pure function of its explicit ``log`` / ``schedule`` +/ ``today`` arguments and never reaches into on-disk state, so the band +boundaries and the period edges are testable with synthetic data. +""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import TYPE_CHECKING + +from diet_guard._averages import PeriodAverage, period_average +from diet_guard._averages_periods import ( + _resolve_today, + _shift_months, + last_complete_day, + month_bounds, + week_bounds, +) + +if TYPE_CHECKING: + from diet_guard._budget_history import BudgetSchedule + from diet_guard._state import DayLog + +_DAYS_PER_WEEK = 7 +_MONTHS_PER_YEAR = 12 + + +def _capped( + log: DayLog, + schedule: BudgetSchedule, + bounds: tuple[date, date], + ref: date, +) -> PeriodAverage: + """Average ``bounds``, truncated so it never includes ``ref`` itself. + + The single place the "today is excluded" rule is applied, so a caller + cannot construct a period that half-counts an unfinished day. + """ + start, end = bounds + return period_average( + log, + schedule=schedule, + start=start, + end=min(end, last_complete_day(ref)), + ) + + +def weekly_average( + log: DayLog, + *, + schedule: BudgetSchedule, + weeks_ago: int = 0, + today: date | None = None, +) -> PeriodAverage: + """Return the average for an ISO week, ``weeks_ago`` weeks back. + + Args: + log: A filtered log (see :func:`period_average`). + schedule: Resolves each day's budget. + weeks_ago: 0 for the current week (through yesterday), 1 for the + previous complete week, and so on. + today: The reference "today"; defaults to the real current date. + + Returns: + That week's :class:`PeriodAverage`. + """ + ref = _resolve_today(today) + anchor = ref - timedelta(weeks=weeks_ago) + return _capped(log, schedule, week_bounds(anchor), ref) + + +def monthly_average( + log: DayLog, + *, + schedule: BudgetSchedule, + months_ago: int = 0, + today: date | None = None, +) -> PeriodAverage: + """Return the average for a calendar month, ``months_ago`` months back. + + Args: + log: A filtered log (see :func:`period_average`). + schedule: Resolves each day's budget. + months_ago: 0 for the current month (through yesterday), 1 for the + previous complete month, and so on. + today: The reference "today"; defaults to the real current date. + + Returns: + That month's :class:`PeriodAverage`. + """ + ref = _resolve_today(today) + return _capped(log, schedule, month_bounds(_shift_months(ref, months_ago)), ref) + + +def recent_weeks( + log: DayLog, + *, + schedule: BudgetSchedule, + count: int, + today: date | None = None, +) -> tuple[PeriodAverage, ...]: + """Return the last ``count`` weekly averages, most recent first.""" + ref = _resolve_today(today) + return tuple( + weekly_average(log, schedule=schedule, weeks_ago=back, today=ref) + for back in range(count) + ) + + +def recent_months( + log: DayLog, + *, + schedule: BudgetSchedule, + count: int, + today: date | None = None, +) -> tuple[PeriodAverage, ...]: + """Return the last ``count`` monthly averages, most recent first.""" + ref = _resolve_today(today) + return tuple( + monthly_average(log, schedule=schedule, months_ago=back, today=ref) + for back in range(count) + ) diff --git a/diet_guard/_budget.py b/diet_guard/_budget.py index 1f39d5b..a1c1cbf 100644 --- a/diet_guard/_budget.py +++ b/diet_guard/_budget.py @@ -19,7 +19,6 @@ keeps up to date on every edit. from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timezone import json import logging @@ -83,65 +82,6 @@ class BudgetFileCorruptError(BudgetError): super().__init__("daily budget file is corrupt") -@dataclass(frozen=True) -class Biometrics: - """Body metrics that feed the Mifflin-St Jeor budget formula. - - Grouped into one value object so the budget calculation stays under the - repo's five-argument lint ceiling and so the inputs travel together. - - Attributes: - weight_kg: Body mass in kilograms. - height_cm: Height in centimetres. - age_years: Age in years. - is_male: True for the male BMR constant (+5), False for female (-161). - """ - - weight_kg: float - height_cm: float - age_years: float - is_male: bool - - -def mifflin_st_jeor_bmr(bio: Biometrics) -> float: - """Return resting metabolic rate via the Mifflin-St Jeor equation. - - Args: - bio: The person's body metrics. - - Returns: - Basal metabolic rate in kcal/day. - """ - base = 10.0 * bio.weight_kg + 6.25 * bio.height_cm - 5.0 * bio.age_years - return base + 5.0 if bio.is_male else base - 161.0 - - -def compute_target_budget( - bio: Biometrics, - *, - activity_factor: float, - deficit_kcal: float, -) -> int: - """Return the daily kcal target: TDEE minus a deficit, floored for safety. - - TDEE (total daily energy expenditure) is the BMR scaled by an activity - factor; subtracting a deficit yields a target that drives gradual loss. - - Args: - bio: The person's body metrics. - activity_factor: Multiplier for daily activity (e.g. 1.2 sedentary, - 1.375 light, 1.55 moderate, 1.725 very active). - deficit_kcal: Calories subtracted from TDEE for weight loss. - - Returns: - The target budget in kcal, never below ``_MIN_SANE_BUDGET``. - """ - bmr = mifflin_st_jeor_bmr(bio) - tdee = bmr * activity_factor - target = round(tdee - deficit_kcal) - return max(target, _MIN_SANE_BUDGET) - - def is_initialized() -> bool: """Return True if a budget file exists on disk.""" return BUDGET_FILE.exists() @@ -254,43 +194,6 @@ def daily_budget() -> int: return value -def budget_weight() -> float | None: - """Return the body weight stored with the budget, or None if unavailable. - - Returns: - The stored weight in kg, or None for a pre-v2 (budget-only) record. - - Raises: - BudgetNotInitializedError: If no budget has been set yet. - BudgetFileCorruptError: If the file exists but cannot be parsed. - """ - record = _read_record() - value = record.get("w") - if isinstance(value, bool) or not isinstance(value, (int, float)): - return None - return float(value) - - -def protein_target_g() -> float | None: - """Return the daily protein target in grams, or None if it cannot be derived. - - Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns - None -- rather than raising -- whenever the target is simply unavailable - (no budget set, a pre-v2 record without weight, or a corrupt file), so - the dashboard can show calories and quietly omit the protein line. - - Returns: - The protein target in grams, or None when weight is unknown. - """ - try: - weight = budget_weight() - except BudgetError: - return None - if weight is None: - return None - return round(weight * PROTEIN_G_PER_KG, 1) - - def read_raw_record() -> dict[str, object] | None: """Return the on-disk budget record verbatim, or None if unset/corrupt. diff --git a/diet_guard/_budget_biometrics.py b/diet_guard/_budget_biometrics.py new file mode 100644 index 0000000..ee79688 --- /dev/null +++ b/diet_guard/_budget_biometrics.py @@ -0,0 +1,80 @@ +"""Biometrics and the one-time budget computation seeded from them. + +Split out of :mod:`._budget` to keep both files under the repo's 250-line +limit. This half is pure arithmetic -- Mifflin-St Jeor BMR, the activity and +goal multipliers -- and touches no file, which is why ``BUDGET_FILE`` stays +named by :mod:`._budget` alone. + +The biometrics themselves are used once and discarded: only the computed +budget is ever persisted. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# The floor a computed target can never go below, however aggressive the +# deficit. Defined here with the computation it guards rather than imported +# from _budget, which would make the dependency circular. +_MIN_SANE_BUDGET = 1200 + +__all__ = ["Biometrics", "compute_target_budget", "mifflin_st_jeor_bmr"] + + +@dataclass(frozen=True) +class Biometrics: + """Body metrics that feed the Mifflin-St Jeor budget formula. + + Grouped into one value object so the budget calculation stays under the + repo's five-argument lint ceiling and so the inputs travel together. + + Attributes: + weight_kg: Body mass in kilograms. + height_cm: Height in centimetres. + age_years: Age in years. + is_male: True for the male BMR constant (+5), False for female (-161). + """ + + weight_kg: float + height_cm: float + age_years: float + is_male: bool + + +def mifflin_st_jeor_bmr(bio: Biometrics) -> float: + """Return resting metabolic rate via the Mifflin-St Jeor equation. + + Args: + bio: The person's body metrics. + + Returns: + Basal metabolic rate in kcal/day. + """ + base = 10.0 * bio.weight_kg + 6.25 * bio.height_cm - 5.0 * bio.age_years + return base + 5.0 if bio.is_male else base - 161.0 + + +def compute_target_budget( + bio: Biometrics, + *, + activity_factor: float, + deficit_kcal: float, +) -> int: + """Return the daily kcal target: TDEE minus a deficit, floored for safety. + + TDEE (total daily energy expenditure) is the BMR scaled by an activity + factor; subtracting a deficit yields a target that drives gradual loss. + + Args: + bio: The person's body metrics. + activity_factor: Multiplier for daily activity (e.g. 1.2 sedentary, + 1.375 light, 1.55 moderate, 1.725 very active). + deficit_kcal: Calories subtracted from TDEE for weight loss. + + Returns: + The target budget in kcal, never below ``_MIN_SANE_BUDGET``. + """ + bmr = mifflin_st_jeor_bmr(bio) + tdee = bmr * activity_factor + target = round(tdee - deficit_kcal) + return max(target, _MIN_SANE_BUDGET) diff --git a/diet_guard/_budget_derived.py b/diet_guard/_budget_derived.py new file mode 100644 index 0000000..93cc0c8 --- /dev/null +++ b/diet_guard/_budget_derived.py @@ -0,0 +1,51 @@ +"""Values derived from the stored budget record. + +Split out of :mod:`._budget` to keep both files under the repo's 250-line +limit. Nothing here opens a file: both helpers read through +:func:`diet_guard._budget._read_record`, so ``BUDGET_FILE`` stays named by +:mod:`._budget` alone -- which is what ``tests/conftest.py`` redirects, and +what ``test_state_redirect.py`` enforces. +""" + +from __future__ import annotations + +from diet_guard._budget import PROTEIN_G_PER_KG, BudgetError, _read_record + +__all__ = ["budget_weight", "protein_target_g"] + + +def protein_target_g() -> float | None: + """Return the daily protein target in grams, or None if it cannot be derived. + + Derived from the stored body weight at :data:`PROTEIN_G_PER_KG`. Returns + None -- rather than raising -- whenever the target is simply unavailable + (no budget set, a pre-v2 record without weight, or a corrupt file), so + the dashboard can show calories and quietly omit the protein line. + + Returns: + The protein target in grams, or None when weight is unknown. + """ + try: + weight = budget_weight() + except BudgetError: + return None + if weight is None: + return None + return round(weight * PROTEIN_G_PER_KG, 1) + + +def budget_weight() -> float | None: + """Return the body weight stored with the budget, or None if unavailable. + + Returns: + The stored weight in kg, or None for a pre-v2 (budget-only) record. + + Raises: + BudgetNotInitializedError: If no budget has been set yet. + BudgetFileCorruptError: If the file exists but cannot be parsed. + """ + record = _read_record() + value = record.get("w") + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) diff --git a/diet_guard/_calendar_view.py b/diet_guard/_calendar_view.py index 3dfd673..1525744 100644 --- a/diet_guard/_calendar_view.py +++ b/diet_guard/_calendar_view.py @@ -16,12 +16,8 @@ from typing import TYPE_CHECKING from gatelock import LockConfig -from diet_guard._averages import ( - PeriodAverage, - band_label, - monthly_average, - weekly_average, -) +from diet_guard._averages import PeriodAverage, band_label +from diet_guard._averages_report import monthly_average, weekly_average from diet_guard._daystatus import ( DayStatus, adherence_streak, diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 3a019b2..56e9600 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -23,8 +23,8 @@ from diet_guard._budget import ( BudgetFileCorruptError, BudgetNotInitializedError, daily_budget, - protein_target_g, ) +from diet_guard._budget_derived import protein_target_g from diet_guard._cli_args import parse_args from diet_guard._cli_averages import cmd_averages from diet_guard._cli_gate import cmd_gate diff --git a/diet_guard/_cli_averages.py b/diet_guard/_cli_averages.py index 25f1ae4..66f7c05 100644 --- a/diet_guard/_cli_averages.py +++ b/diet_guard/_cli_averages.py @@ -16,11 +16,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from diet_guard._averages import ( - band_label, - monthly_average, - weekly_average, -) +from diet_guard._averages import band_label +from diet_guard._averages_report import monthly_average, weekly_average from diet_guard._budget import ( BudgetFileCorruptError, BudgetNotInitializedError, diff --git a/diet_guard/_cli_init.py b/diet_guard/_cli_init.py index b81fcc3..0ed0524 100644 --- a/diet_guard/_cli_init.py +++ b/diet_guard/_cli_init.py @@ -13,7 +13,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from diet_guard._budget import Biometrics, compute_target_budget, write_budget +from diet_guard._budget import write_budget +from diet_guard._budget_biometrics import Biometrics, compute_target_budget if TYPE_CHECKING: from collections.abc import Callable diff --git a/diet_guard/_estimator.py b/diet_guard/_estimator.py index 53044e9..54e8f89 100644 --- a/diet_guard/_estimator.py +++ b/diet_guard/_estimator.py @@ -16,16 +16,6 @@ from __future__ import annotations from dataclasses import dataclass, replace import logging -import requests - -from diet_guard._constants import ( - DEFAULT_PORTION_GRAMS, - OFF_PAGE_SIZE, - OFF_SEARCH_URL, - OFF_TIMEOUT_SECONDS, - OFF_USER_AGENT, -) - _logger = logging.getLogger(__name__) # Open Food Facts nutriment field names (values are "per 100 g"). @@ -145,181 +135,3 @@ def scale_nutrition(nutrition: Nutrition, grams: float) -> Nutrition: fat_g=round(nutrition.fat_g * factor, 1), grams=round(grams, 1), ) - - -def _off_search(term: str) -> list[dict[str, object]]: - """Query Open Food Facts for products matching ``term``. - - Args: - term: Free-text food description. - - Returns: - A list of product dicts (possibly empty), most relevant first. - - Raises: - requests.RequestException: On any network or HTTP failure. - """ - params = { - "q": term, - "fields": "product_name,nutriments,serving_quantity", - "page_size": str(OFF_PAGE_SIZE), - } - response = requests.get( - OFF_SEARCH_URL, - params=params, - headers={"User-Agent": OFF_USER_AGENT}, - timeout=OFF_TIMEOUT_SECONDS, - ) - response.raise_for_status() - payload = response.json() - if not isinstance(payload, dict): - return [] - # Search-a-licious returns matches under "hits" (ranked by relevance). - hits = payload.get("hits", []) - if not isinstance(hits, list): - return [] - return [hit for hit in hits if isinstance(hit, dict)] - - -def _products_with_energy( - products: list[dict[str, object]], -) -> list[tuple[dict[str, object], dict[str, object]]]: - """Return the products that carry a usable kcal/100 g value, in order. - - Args: - products: Product dicts from :func:`_off_search`. - - Returns: - ``(product, nutriments)`` tuples for every product with a kcal value, - preserving Open Food Facts' relevance order. - """ - matches: list[tuple[dict[str, object], dict[str, object]]] = [] - for product in products: - nutriments = product.get("nutriments") - if not isinstance(nutriments, dict): - continue - if _as_float(nutriments.get(_OFF_KCAL_FIELD)) is not None: - matches.append((product, nutriments)) - return matches - - -def _resolve_portion(grams: float | None, product: dict[str, object]) -> float: - """Decide the portion size, in grams, to use for an estimate. - - Priority: an explicit ``grams`` argument, then the product's Open Food - Facts serving size, then the configured default. Keeping ``--grams`` - optional is deliberate: per-entry friction is the whole reason food diaries - get abandoned, so ``ate "big mac"`` must just work. - - Args: - grams: Caller-supplied portion, or None. - product: OFF product dict (may carry ``serving_quantity``). - - Returns: - A portion size in grams, always greater than zero. - """ - if grams is not None and grams > 0: - return float(grams) - serving = _as_float(product.get("serving_quantity")) - if serving is not None and serving > 0: - return serving - return DEFAULT_PORTION_GRAMS - - -def _off_nutrition( - product: dict[str, object], - nutriments: dict[str, object], - grams: float | None, - description: str, -) -> Nutrition: - """Build a Nutrition for one OFF product, scaled to the chosen portion.""" - portion = _resolve_portion(grams, product) - factor = portion / _GRAMS_PER_REFERENCE - name = product.get("product_name") - label = name if isinstance(name, str) and name.strip() else description - return Nutrition( - kcal=round(_scaled(nutriments, _OFF_KCAL_FIELD, factor), 1), - protein_g=round(_scaled(nutriments, _OFF_PROTEIN_FIELD, factor), 1), - carbs_g=round(_scaled(nutriments, _OFF_CARBS_FIELD, factor), 1), - fat_g=round(_scaled(nutriments, _OFF_FAT_FIELD, factor), 1), - grams=round(portion, 1), - source=f"openfoodfacts: {label}", - ) - - -def off_candidates( - description: str, - grams: float | None = None, - limit: int = OFF_PAGE_SIZE, -) -> list[Nutrition]: - """Return up to ``limit`` Open Food Facts matches for ``description``. - - Returning several candidates (rather than only the top hit) lets the gate - show alternatives so the user can pick the product that actually matches - what they ate, instead of silently accepting the first guess. - - Args: - description: Free-text food description (e.g. ``"big mac"``). - grams: Portion size in grams; serving size or the default is used when - None. - limit: Maximum number of candidates to return. - - Returns: - Nutrition estimates in OFF relevance order (empty if OFF is unreachable - or has no usable match). - """ - try: - products = _off_search(description) - except requests.RequestException as exc: - _logger.warning("Open Food Facts request failed: %s", exc) - return [] - return [ - _off_nutrition(product, nutriments, grams, description) - for product, nutriments in _products_with_energy(products)[:limit] - ] - - -def estimate_off(description: str, grams: float | None) -> Nutrition | None: - """Estimate nutrition for ``description`` via Open Food Facts (top match). - - Args: - description: Free-text food description (e.g. ``"big mac"``). - grams: Portion size in grams. When None, the product's serving size - is used if known, otherwise the configured default portion. - - Returns: - The best Nutrition estimate, or None if OFF is unreachable or has no - usable match (the caller should then fall back to a manual value). - """ - candidates = off_candidates(description, grams, limit=1) - return candidates[0] if candidates else None - - -def _scaled(nutriments: dict[str, object], field: str, factor: float) -> float: - """Return a per-100 g nutriment scaled to the portion (0 if missing).""" - per_reference = _as_float(nutriments.get(field)) - if per_reference is None: - return 0.0 - return per_reference * factor - - -def estimate( - description: str, - *, - grams: float | None = None, - manual_kcal: float | None = None, -) -> Nutrition | None: - """Estimate nutrition for a meal; a manual value takes precedence. - - Args: - description: Free-text food description. - grams: Optional portion size in grams. - manual_kcal: If given, used directly and Open Food Facts is skipped. - - Returns: - A Nutrition estimate, or None when no manual value was supplied and OFF - could not produce a usable match. - """ - if manual_kcal is not None: - return manual(manual_kcal, grams) - return estimate_off(description, grams) diff --git a/diet_guard/_estimator_off.py b/diet_guard/_estimator_off.py new file mode 100644 index 0000000..b05d1af --- /dev/null +++ b/diet_guard/_estimator_off.py @@ -0,0 +1,214 @@ +"""Calorie/macro estimation backends for diet_guard. + +The default backend queries the public Open Food Facts (OFF) database over +HTTP -- no API key required. It is strongest for branded/packaged foods +(fast food included, which is the binge target) and weaker for generic +home-cooked descriptions; in the latter case the caller should fall back to a +manual ``--kcal`` value. + +The backend is intentionally small and pluggable: replace :func:`estimate` +with a local-LLM (ollama) or remote-LLM implementation later without touching +the log/state or CLI layers. +""" + +from __future__ import annotations + +import logging + +import requests + +from diet_guard._constants import ( + DEFAULT_PORTION_GRAMS, + OFF_PAGE_SIZE, + OFF_SEARCH_URL, + OFF_TIMEOUT_SECONDS, + OFF_USER_AGENT, +) +from diet_guard._estimator import Nutrition, _as_float, manual + +_logger = logging.getLogger(__name__) + +# Open Food Facts nutriment field names (values are "per 100 g"). +_OFF_KCAL_FIELD = "energy-kcal_100g" +_OFF_PROTEIN_FIELD = "proteins_100g" +_OFF_CARBS_FIELD = "carbohydrates_100g" +_OFF_FAT_FIELD = "fat_100g" +_GRAMS_PER_REFERENCE = 100.0 + + +def _off_search(term: str) -> list[dict[str, object]]: + """Query Open Food Facts for products matching ``term``. + + Args: + term: Free-text food description. + + Returns: + A list of product dicts (possibly empty), most relevant first. + + Raises: + requests.RequestException: On any network or HTTP failure. + """ + params = { + "q": term, + "fields": "product_name,nutriments,serving_quantity", + "page_size": str(OFF_PAGE_SIZE), + } + response = requests.get( + OFF_SEARCH_URL, + params=params, + headers={"User-Agent": OFF_USER_AGENT}, + timeout=OFF_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + return [] + # Search-a-licious returns matches under "hits" (ranked by relevance). + hits = payload.get("hits", []) + if not isinstance(hits, list): + return [] + return [hit for hit in hits if isinstance(hit, dict)] + + +def _products_with_energy( + products: list[dict[str, object]], +) -> list[tuple[dict[str, object], dict[str, object]]]: + """Return the products that carry a usable kcal/100 g value, in order. + + Args: + products: Product dicts from :func:`_off_search`. + + Returns: + ``(product, nutriments)`` tuples for every product with a kcal value, + preserving Open Food Facts' relevance order. + """ + matches: list[tuple[dict[str, object], dict[str, object]]] = [] + for product in products: + nutriments = product.get("nutriments") + if not isinstance(nutriments, dict): + continue + if _as_float(nutriments.get(_OFF_KCAL_FIELD)) is not None: + matches.append((product, nutriments)) + return matches + + +def _resolve_portion(grams: float | None, product: dict[str, object]) -> float: + """Decide the portion size, in grams, to use for an estimate. + + Priority: an explicit ``grams`` argument, then the product's Open Food + Facts serving size, then the configured default. Keeping ``--grams`` + optional is deliberate: per-entry friction is the whole reason food diaries + get abandoned, so ``ate "big mac"`` must just work. + + Args: + grams: Caller-supplied portion, or None. + product: OFF product dict (may carry ``serving_quantity``). + + Returns: + A portion size in grams, always greater than zero. + """ + if grams is not None and grams > 0: + return float(grams) + serving = _as_float(product.get("serving_quantity")) + if serving is not None and serving > 0: + return serving + return DEFAULT_PORTION_GRAMS + + +def _off_nutrition( + product: dict[str, object], + nutriments: dict[str, object], + grams: float | None, + description: str, +) -> Nutrition: + """Build a Nutrition for one OFF product, scaled to the chosen portion.""" + portion = _resolve_portion(grams, product) + factor = portion / _GRAMS_PER_REFERENCE + name = product.get("product_name") + label = name if isinstance(name, str) and name.strip() else description + return Nutrition( + kcal=round(_scaled(nutriments, _OFF_KCAL_FIELD, factor), 1), + protein_g=round(_scaled(nutriments, _OFF_PROTEIN_FIELD, factor), 1), + carbs_g=round(_scaled(nutriments, _OFF_CARBS_FIELD, factor), 1), + fat_g=round(_scaled(nutriments, _OFF_FAT_FIELD, factor), 1), + grams=round(portion, 1), + source=f"openfoodfacts: {label}", + ) + + +def off_candidates( + description: str, + grams: float | None = None, + limit: int = OFF_PAGE_SIZE, +) -> list[Nutrition]: + """Return up to ``limit`` Open Food Facts matches for ``description``. + + Returning several candidates (rather than only the top hit) lets the gate + show alternatives so the user can pick the product that actually matches + what they ate, instead of silently accepting the first guess. + + Args: + description: Free-text food description (e.g. ``"big mac"``). + grams: Portion size in grams; serving size or the default is used when + None. + limit: Maximum number of candidates to return. + + Returns: + Nutrition estimates in OFF relevance order (empty if OFF is unreachable + or has no usable match). + """ + try: + products = _off_search(description) + except requests.RequestException as exc: + _logger.warning("Open Food Facts request failed: %s", exc) + return [] + return [ + _off_nutrition(product, nutriments, grams, description) + for product, nutriments in _products_with_energy(products)[:limit] + ] + + +def estimate_off(description: str, grams: float | None) -> Nutrition | None: + """Estimate nutrition for ``description`` via Open Food Facts (top match). + + Args: + description: Free-text food description (e.g. ``"big mac"``). + grams: Portion size in grams. When None, the product's serving size + is used if known, otherwise the configured default portion. + + Returns: + The best Nutrition estimate, or None if OFF is unreachable or has no + usable match (the caller should then fall back to a manual value). + """ + candidates = off_candidates(description, grams, limit=1) + return candidates[0] if candidates else None + + +def _scaled(nutriments: dict[str, object], field: str, factor: float) -> float: + """Return a per-100 g nutriment scaled to the portion (0 if missing).""" + per_reference = _as_float(nutriments.get(field)) + if per_reference is None: + return 0.0 + return per_reference * factor + + +def estimate( + description: str, + *, + grams: float | None = None, + manual_kcal: float | None = None, +) -> Nutrition | None: + """Estimate nutrition for a meal; a manual value takes precedence. + + Args: + description: Free-text food description. + grams: Optional portion size in grams. + manual_kcal: If given, used directly and Open Food Facts is skipped. + + Returns: + A Nutrition estimate, or None when no manual value was supplied and OFF + could not produce a usable match. + """ + if manual_kcal is not None: + return manual(manual_kcal, grams) + return estimate_off(description, grams) diff --git a/diet_guard/_foodbank.py b/diet_guard/_foodbank.py index dd6cced..e9beaae 100644 --- a/diet_guard/_foodbank.py +++ b/diet_guard/_foodbank.py @@ -23,15 +23,12 @@ import time from diet_guard._coerce import as_float from diet_guard._constants import FOOD_BANK_FILE from diet_guard._estimator import Nutrition -from diet_guard._foodbank_manual import read_manual_bank -from diet_guard._fuzzy import match_score _logger = logging.getLogger(__name__) # Below this similarity ratio a non-substring candidate is not a plausible typo # of the query and is dropped. SequenceMatcher's own "close match" default is # 0.6; we reuse it so behavior matches difflib intuitions. -_FUZZY_THRESHOLD = 0.6 # Default number of autocomplete suggestions to surface. DEFAULT_SUGGESTIONS = 8 @@ -208,158 +205,3 @@ def _upsert( bank = _read_bank() _apply_upsert(bank, description, nutrition, components=components) _write_bank(bank) - - -def _entry_nutrition(entry: dict[str, object], *, source: str) -> Nutrition: - """Build a :class:`Nutrition` from a raw log entry's macro fields.""" - return Nutrition( - kcal=as_float(entry.get("kcal")), - protein_g=as_float(entry.get("protein_g")), - carbs_g=as_float(entry.get("carbs_g")), - fat_g=as_float(entry.get("fat_g")), - grams=as_float(entry.get("grams")), - source=source, - ) - - -def rebuild_food_bank(log: dict[str, list[dict[str, object]]]) -> dict[str, BankRecord]: - """Rebuild the bank from scratch by replaying ``log``'s entries, then persist it. - - Replays in a fixed, device-independent order (by ``time`` then ``id``), - so two devices that converge on the same merged log also converge on the - same bank -- this is what lets the food bank stay *derived*, never - synced, with no counter-merge (CRDT) logic needed for ``count``. Mirrors - the Dart port's ``FoodBankService.rebuild`` exactly, including the - composite-meal branch (banks each component, then the composite itself). - - Deleted (tombstoned) entries are skipped entirely, same as - :func:`diet_guard._state.load_log`. - - Args: - log: A full log keyed by date, e.g. from - :func:`diet_guard._state.read_raw_log` after a sync merge. - - Returns: - The freshly rebuilt bank (also written to disk). - """ - entries = sorted( - ( - entry - for day_entries in log.values() - for entry in day_entries - if not entry.get("deleted") - ), - key=lambda entry: (str(entry.get("time", "")), str(entry.get("id", ""))), - ) - bank: dict[str, BankRecord] = {} - for entry in entries: - components = entry.get("components") - component_names: list[str] | None = None - if isinstance(components, list): - component_names = [] - for component in components: - if not isinstance(component, dict): - continue - name = str(component.get("name", "")) - component_names.append(name) - _apply_upsert( - bank, - name, - _entry_nutrition(component, source="food bank"), - components=None, - ) - _apply_upsert( - bank, - str(entry.get("desc", "")), - _entry_nutrition(entry, source=str(entry.get("source", "manual"))), - components=component_names, - ) - _write_bank(bank) - return bank - - -def _all_records() -> dict[str, BankRecord]: - """Return curated entries merged under the log-derived ones. - - Log-derived records win on a name collision: they carry a real ``count`` - and the macros the food was actually logged with, whereas a curated entry - is only a starting point. Mirrors ``foodbank_service.dart``'s - ``{...manualBank, ...logBank}``. - """ - return {**read_manual_bank(), **_read_bank()} - - -def lookup_food(description: str) -> Nutrition | None: - """Return the exact-match macros for ``description``, or None. - - Searches curated entries as well as logged ones, so a food added by hand - on either device resolves here too. - - Args: - description: The food name to look up verbatim (case-insensitive). - - Returns: - The stored Nutrition, or None if the food is not banked. - """ - record = _all_records().get(_normalize(description)) - return _record_to_nutrition(record) if record is not None else None - - -def _display_name(record: BankRecord, key: str) -> str: - """Return a record's display name, falling back to its key.""" - desc = record.get("desc") - return desc if isinstance(desc, str) and desc.strip() else key - - -def search_foods( - query: str, - limit: int = DEFAULT_SUGGESTIONS, -) -> list[tuple[str, Nutrition]]: - """Return banked foods matching ``query``, best match first. - - An empty query returns the most-logged foods (the expandable full list). - A non-empty query keeps substring and close-typo matches, ranked by match - quality then by use count. Searches curated entries (added by hand on - either device, see :mod:`diet_guard._foodbank_manual`) as well as logged - ones; a logged record wins on a name collision. - - Args: - query: Free-text the user has typed so far. - limit: Maximum number of suggestions to return. - - Returns: - ``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long. - """ - bank = _all_records() - normalized = _normalize(query) - if not normalized: - return _ranked_all(bank, limit) - - scored: list[tuple[float, float, str, Nutrition]] = [] - for key, record in bank.items(): - score = match_score(normalized, key) - if score < _FUZZY_THRESHOLD: - continue - count = as_float(record.get("count")) - scored.append( - (score, count, _display_name(record, key), _record_to_nutrition(record)), - ) - # Sort by score then frequency, both descending. - scored.sort(key=lambda item: (item[0], item[1]), reverse=True) - return [(name, nutrition) for _, _, name, nutrition in scored[:limit]] - - -def _ranked_all( - bank: dict[str, BankRecord], - limit: int, -) -> list[tuple[str, Nutrition]]: - """Return all banked foods ranked by use count, most-logged first.""" - ranked = sorted( - bank.items(), - key=lambda item: as_float(item[1].get("count")), - reverse=True, - ) - return [ - (_display_name(record, key), _record_to_nutrition(record)) - for key, record in ranked[:limit] - ] diff --git a/diet_guard/_foodbank_rebuild.py b/diet_guard/_foodbank_rebuild.py new file mode 100644 index 0000000..8e151f7 --- /dev/null +++ b/diet_guard/_foodbank_rebuild.py @@ -0,0 +1,85 @@ +"""Rebuilding the derived food bank from the meal log. + +Split out of :mod:`._foodbank` to keep both files under the repo's 250-line +limit. The sync tick calls :func:`rebuild_food_bank` after a merge. It *does* +persist, but through :mod:`._foodbank`'s private ``_write_bank`` rather than +opening the file here, so ``FOOD_BANK_FILE`` stays named by :mod:`._foodbank` +alone -- which is what ``tests/conftest.py`` redirects, and what +``test_state_redirect.py`` enforces. +""" + +from __future__ import annotations + +from diet_guard._coerce import as_float +from diet_guard._estimator import Nutrition +from diet_guard._foodbank import BankRecord, _apply_upsert, _write_bank + +__all__ = ["rebuild_food_bank"] + + +def _entry_nutrition(entry: dict[str, object], *, source: str) -> Nutrition: + """Build a :class:`Nutrition` from a raw log entry's macro fields.""" + return Nutrition( + kcal=as_float(entry.get("kcal")), + protein_g=as_float(entry.get("protein_g")), + carbs_g=as_float(entry.get("carbs_g")), + fat_g=as_float(entry.get("fat_g")), + grams=as_float(entry.get("grams")), + source=source, + ) + + +def rebuild_food_bank(log: dict[str, list[dict[str, object]]]) -> dict[str, BankRecord]: + """Rebuild the bank from scratch by replaying ``log``'s entries, then persist it. + + Replays in a fixed, device-independent order (by ``time`` then ``id``), + so two devices that converge on the same merged log also converge on the + same bank -- this is what lets the food bank stay *derived*, never + synced, with no counter-merge (CRDT) logic needed for ``count``. Mirrors + the Dart port's ``FoodBankService.rebuild`` exactly, including the + composite-meal branch (banks each component, then the composite itself). + + Deleted (tombstoned) entries are skipped entirely, same as + :func:`diet_guard._state.load_log`. + + Args: + log: A full log keyed by date, e.g. from + :func:`diet_guard._state.read_raw_log` after a sync merge. + + Returns: + The freshly rebuilt bank (also written to disk). + """ + entries = sorted( + ( + entry + for day_entries in log.values() + for entry in day_entries + if not entry.get("deleted") + ), + key=lambda entry: (str(entry.get("time", "")), str(entry.get("id", ""))), + ) + bank: dict[str, BankRecord] = {} + for entry in entries: + components = entry.get("components") + component_names: list[str] | None = None + if isinstance(components, list): + component_names = [] + for component in components: + if not isinstance(component, dict): + continue + name = str(component.get("name", "")) + component_names.append(name) + _apply_upsert( + bank, + name, + _entry_nutrition(component, source="food bank"), + components=None, + ) + _apply_upsert( + bank, + str(entry.get("desc", "")), + _entry_nutrition(entry, source=str(entry.get("source", "manual"))), + components=component_names, + ) + _write_bank(bank) + return bank diff --git a/diet_guard/_foodbank_search.py b/diet_guard/_foodbank_search.py new file mode 100644 index 0000000..d8a0599 --- /dev/null +++ b/diet_guard/_foodbank_search.py @@ -0,0 +1,118 @@ +"""Lookup and ranked search over the food banks. + +Split out of :mod:`._foodbank` to keep both files under the repo's 250-line +limit. Read-only: everything here goes through :func:`._foodbank.read_food_bank` +and the curated bank, so ``FOOD_BANK_FILE`` stays named by :mod:`._foodbank` +alone -- which is what ``tests/conftest.py`` redirects, and what +``test_state_redirect.py`` enforces. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from diet_guard._coerce import as_float +from diet_guard._foodbank import ( + DEFAULT_SUGGESTIONS, + BankRecord, + _normalize, + _read_bank, + _record_to_nutrition, +) +from diet_guard._foodbank_manual import read_manual_bank +from diet_guard._fuzzy import match_score + +if TYPE_CHECKING: + from diet_guard._estimator import Nutrition + +# Minimum SequenceMatcher ratio for a fuzzy name match to be offered. +_FUZZY_THRESHOLD = 0.6 + +__all__ = ["_all_records", "lookup_food", "search_foods"] + + +def _all_records() -> dict[str, BankRecord]: + """Return curated entries merged under the log-derived ones. + + Log-derived records win on a name collision: they carry a real ``count`` + and the macros the food was actually logged with, whereas a curated entry + is only a starting point. Mirrors ``foodbank_service.dart``'s + ``{...manualBank, ...logBank}``. + """ + return {**read_manual_bank(), **_read_bank()} + + +def lookup_food(description: str) -> Nutrition | None: + """Return the exact-match macros for ``description``, or None. + + Searches curated entries as well as logged ones, so a food added by hand + on either device resolves here too. + + Args: + description: The food name to look up verbatim (case-insensitive). + + Returns: + The stored Nutrition, or None if the food is not banked. + """ + record = _all_records().get(_normalize(description)) + return _record_to_nutrition(record) if record is not None else None + + +def _display_name(record: BankRecord, key: str) -> str: + """Return a record's display name, falling back to its key.""" + desc = record.get("desc") + return desc if isinstance(desc, str) and desc.strip() else key + + +def search_foods( + query: str, + limit: int = DEFAULT_SUGGESTIONS, +) -> list[tuple[str, Nutrition]]: + """Return banked foods matching ``query``, best match first. + + An empty query returns the most-logged foods (the expandable full list). + A non-empty query keeps substring and close-typo matches, ranked by match + quality then by use count. Searches curated entries (added by hand on + either device, see :mod:`diet_guard._foodbank_manual`) as well as logged + ones; a logged record wins on a name collision. + + Args: + query: Free-text the user has typed so far. + limit: Maximum number of suggestions to return. + + Returns: + ``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long. + """ + bank = _all_records() + normalized = _normalize(query) + if not normalized: + return _ranked_all(bank, limit) + + scored: list[tuple[float, float, str, Nutrition]] = [] + for key, record in bank.items(): + score = match_score(normalized, key) + if score < _FUZZY_THRESHOLD: + continue + count = as_float(record.get("count")) + scored.append( + (score, count, _display_name(record, key), _record_to_nutrition(record)), + ) + # Sort by score then frequency, both descending. + scored.sort(key=lambda item: (item[0], item[1]), reverse=True) + return [(name, nutrition) for _, _, name, nutrition in scored[:limit]] + + +def _ranked_all( + bank: dict[str, BankRecord], + limit: int, +) -> list[tuple[str, Nutrition]]: + """Return all banked foods ranked by use count, most-logged first.""" + ranked = sorted( + bank.items(), + key=lambda item: as_float(item[1].get("count")), + reverse=True, + ) + return [ + (_display_name(record, key), _record_to_nutrition(record)) + for key, record in ranked[:limit] + ] diff --git a/diet_guard/_gatelock_budgetedit.py b/diet_guard/_gatelock_budgetedit.py index e856c9c..2e28c86 100644 --- a/diet_guard/_gatelock_budgetedit.py +++ b/diet_guard/_gatelock_budgetedit.py @@ -18,7 +18,8 @@ import contextlib import tkinter as tk from typing import TYPE_CHECKING -from diet_guard._budget import BudgetError, budget_weight, write_budget +from diet_guard._budget import BudgetError, write_budget +from diet_guard._budget_derived import budget_weight from diet_guard._gatelock_mealflow import _GateMealFlow from diet_guard._gatelock_ui import ERR, FG diff --git a/diet_guard/_gatelock_dashboard.py b/diet_guard/_gatelock_dashboard.py index fe2cdda..ff91485 100644 --- a/diet_guard/_gatelock_dashboard.py +++ b/diet_guard/_gatelock_dashboard.py @@ -11,7 +11,8 @@ in the meal flow. from __future__ import annotations -from diet_guard._budget import BudgetError, daily_budget, protein_target_g +from diet_guard._budget import BudgetError, daily_budget +from diet_guard._budget_derived import protein_target_g from diet_guard._gatelock_nutrition import _GateNutrition from diet_guard._state import ( entry_kcal, diff --git a/diet_guard/_mcp_read.py b/diet_guard/_mcp_read.py index ad1400c..3fe440f 100644 --- a/diet_guard/_mcp_read.py +++ b/diet_guard/_mcp_read.py @@ -15,12 +15,8 @@ from __future__ import annotations from typing import Any -from diet_guard._averages import ( - PeriodAverage, - band_label, - monthly_average, - weekly_average, -) +from diet_guard._averages import PeriodAverage, band_label +from diet_guard._averages_report import monthly_average, weekly_average from diet_guard._budget import BudgetError, current_schedule, daily_budget from diet_guard._gate import due_slots from diet_guard._mcp_server import READS_ONLY, mcp diff --git a/diet_guard/_resolve.py b/diet_guard/_resolve.py index 266d30b..d7442a7 100644 --- a/diet_guard/_resolve.py +++ b/diet_guard/_resolve.py @@ -19,14 +19,9 @@ from __future__ import annotations from dataclasses import dataclass -from diet_guard._estimator import ( - Nutrition, - estimate_off, - manual, - off_candidates, - scale_nutrition, -) -from diet_guard._foodbank import lookup_food, search_foods +from diet_guard._estimator import Nutrition, manual, scale_nutrition +from diet_guard._estimator_off import estimate_off, off_candidates +from diet_guard._foodbank_search import lookup_food, search_foods from diet_guard._portions import staple_nutrition, suggest_staples diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index fe87a94..df01374 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -36,7 +36,7 @@ from diet_guard._constants import ( SYNC_STATE_FILE, ) from diet_guard._device import device_identity -from diet_guard._foodbank import rebuild_food_bank +from diet_guard._foodbank_rebuild import rebuild_food_bank from diet_guard._state_sync import ( read_raw_log, resign_entry, diff --git a/diet_guard/tests/test_averages.py b/diet_guard/tests/test_averages.py index 0348ce3..84aabc9 100644 --- a/diet_guard/tests/test_averages.py +++ b/diet_guard/tests/test_averages.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date, timedelta -from diet_guard import _averages +from diet_guard import _averages, _averages_periods from diet_guard._averages import AverageBand from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule @@ -74,41 +74,47 @@ class TestBandLabel: class TestPeriodBounds: def test_week_starts_monday(self) -> None: # 2026-01-07 is a Wednesday. - start, end = _averages.week_bounds(date(2026, 1, 7)) + start, end = _averages_periods.week_bounds(date(2026, 1, 7)) assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) def test_week_of_a_monday_is_that_monday(self) -> None: - start, end = _averages.week_bounds(date(2026, 1, 5)) + start, end = _averages_periods.week_bounds(date(2026, 1, 5)) assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) def test_week_of_a_sunday_looks_back(self) -> None: - start, end = _averages.week_bounds(date(2026, 1, 11)) + start, end = _averages_periods.week_bounds(date(2026, 1, 11)) assert (start, end) == (date(2026, 1, 5), date(2026, 1, 11)) def test_month_bounds(self) -> None: - start, end = _averages.month_bounds(date(2026, 2, 14)) + start, end = _averages_periods.month_bounds(date(2026, 2, 14)) assert (start, end) == (date(2026, 2, 1), date(2026, 2, 28)) def test_month_bounds_leap_february(self) -> None: - _, end = _averages.month_bounds(date(2024, 2, 14)) + _, end = _averages_periods.month_bounds(date(2024, 2, 14)) assert end == date(2024, 2, 29) def test_last_complete_day_is_yesterday(self) -> None: - assert _averages.last_complete_day(date(2026, 3, 1)) == date(2026, 2, 28) + assert _averages_periods.last_complete_day(date(2026, 3, 1)) == date( + 2026, 2, 28 + ) class TestShiftMonths: def test_zero_is_the_same_month(self) -> None: - assert _averages._shift_months(date(2026, 5, 20), 0) == date(2026, 5, 1) + assert _averages_periods._shift_months(date(2026, 5, 20), 0) == date(2026, 5, 1) def test_one_back(self) -> None: - assert _averages._shift_months(date(2026, 5, 20), 1) == date(2026, 4, 1) + assert _averages_periods._shift_months(date(2026, 5, 20), 1) == date(2026, 4, 1) def test_wraps_the_year(self) -> None: - assert _averages._shift_months(date(2026, 1, 20), 1) == date(2025, 12, 1) + assert _averages_periods._shift_months(date(2026, 1, 20), 1) == date( + 2025, 12, 1 + ) def test_wraps_several_years(self) -> None: - assert _averages._shift_months(date(2026, 3, 20), 27) == date(2023, 12, 1) + assert _averages_periods._shift_months(date(2026, 3, 20), 27) == date( + 2023, 12, 1 + ) class TestPeriodAverage: @@ -220,132 +226,3 @@ class TestPeriodAverage: end=date(2026, 1, 11), ) assert (result.start, result.end) == ("2026-01-05", "2026-01-11") - - -class TestWeeklyAverage: - def test_current_week_stops_at_yesterday(self) -> None: - # Today is Thursday 2026-01-08 and today's 100 kcal must NOT count: - # the mean is over Mon-Wed only. - log = _days("2026-01-05", [3000, 3000, 3000]) | {"2026-01-08": [_entry(100)]} - result = _averages.weekly_average( - log, - schedule=_flat(2000), - today=date(2026, 1, 8), - ) - assert result.avg_kcal == 3000 - assert result.end == "2026-01-07" - assert result.band is AverageBand.VERY_OVER - - def test_monday_has_no_complete_days_this_week(self) -> None: - log = _days("2026-01-05", [3000]) - result = _averages.weekly_average( - log, - schedule=_flat(2000), - today=date(2026, 1, 5), - ) - assert result.elapsed_days == 0 - assert result.avg_kcal is None - assert result.band is None - - def test_previous_week_is_whole(self) -> None: - log = _days("2026-01-05", [2100, 2100, 2100, 2100, 2100, 2100, 2100]) - result = _averages.weekly_average( - log, - schedule=_flat(2000), - weeks_ago=1, - today=date(2026, 1, 14), - ) - assert (result.start, result.end) == ("2026-01-05", "2026-01-11") - assert result.elapsed_days == 7 - assert result.logged_days == 7 - assert result.band is AverageBand.SLIGHTLY_OVER - - def test_defaults_to_the_real_today(self) -> None: - # No explicit `today`: the call must still resolve a real date range - # rather than raising or returning an unbounded period. - result = _averages.weekly_average({}, schedule=_flat(2000)) - assert result.elapsed_days >= 0 - - -class TestMonthlyAverage: - def test_current_month_stops_at_yesterday(self) -> None: - log = _days("2026-03-01", [1000, 1000, 1000]) | {"2026-03-04": [_entry(9000)]} - result = _averages.monthly_average( - log, - schedule=_flat(2000), - today=date(2026, 3, 4), - ) - assert result.avg_kcal == 1000 - assert (result.start, result.end) == ("2026-03-01", "2026-03-03") - assert result.band is AverageBand.UNDER - - def test_first_of_the_month_has_no_complete_days(self) -> None: - result = _averages.monthly_average( - {}, - schedule=_flat(2000), - today=date(2026, 3, 1), - ) - assert result.elapsed_days == 0 - assert result.avg_kcal is None - - def test_previous_month_is_whole(self) -> None: - log = _days("2026-02-01", [2500, 2500]) - result = _averages.monthly_average( - log, - schedule=_flat(2000), - months_ago=1, - today=date(2026, 3, 10), - ) - assert (result.start, result.end) == ("2026-02-01", "2026-02-28") - assert result.elapsed_days == 28 - assert result.logged_days == 2 - assert result.band is AverageBand.VERY_OVER - - def test_defaults_to_the_real_today(self) -> None: - result = _averages.monthly_average({}, schedule=_flat(2000)) - assert result.elapsed_days >= 0 - - -class TestRecentPeriods: - def test_recent_weeks_are_newest_first(self) -> None: - weeks = _averages.recent_weeks( - {}, - schedule=_flat(2000), - count=3, - today=date(2026, 1, 14), - ) - assert [week.start for week in weeks] == [ - "2026-01-12", - "2026-01-05", - "2025-12-29", - ] - - def test_recent_months_are_newest_first(self) -> None: - months = _averages.recent_months( - {}, - schedule=_flat(2000), - count=3, - today=date(2026, 1, 14), - ) - assert [month.start for month in months] == [ - "2026-01-01", - "2025-12-01", - "2025-11-01", - ] - - def test_zero_count_is_empty(self) -> None: - assert ( - _averages.recent_weeks( - {}, - schedule=_flat(2000), - count=0, - today=date(2026, 1, 14), - ) - == () - ) - - def test_recent_weeks_defaults_to_the_real_today(self) -> None: - assert len(_averages.recent_weeks({}, schedule=_flat(2000), count=2)) == 2 - - def test_recent_months_defaults_to_the_real_today(self) -> None: - assert len(_averages.recent_months({}, schedule=_flat(2000), count=2)) == 2 diff --git a/diet_guard/tests/test_averages_report.py b/diet_guard/tests/test_averages_report.py new file mode 100644 index 0000000..5788463 --- /dev/null +++ b/diet_guard/tests/test_averages_report.py @@ -0,0 +1,163 @@ +"""Tests for diet_guard._averages: period edges, bands, and coverage counts.""" + +from __future__ import annotations + +from datetime import date, timedelta + +from diet_guard import _averages_report +from diet_guard._averages import AverageBand +from diet_guard._budget_history import EPOCH_DAY, BudgetEntry, BudgetSchedule + + +def _entry(kcal: float) -> dict[str, object]: + return {"kcal": kcal} + + +def _flat(budget: int) -> BudgetSchedule: + """A schedule where one budget has applied since the beginning of time.""" + return BudgetSchedule( + (BudgetEntry(EPOCH_DAY, budget, "1970-01-01T00:00:00+00:00"),), + default=budget, + ) + + +def _days(start: str, kcals: list[float]) -> dict[str, list[dict[str, object]]]: + """A log of consecutive days from ``start``, one entry each.""" + first = date.fromisoformat(start) + return { + (first + timedelta(days=offset)).isoformat(): [_entry(kcal)] + for offset, kcal in enumerate(kcals) + } + + +class TestWeeklyAverage: + def test_current_week_stops_at_yesterday(self) -> None: + # Today is Thursday 2026-01-08 and today's 100 kcal must NOT count: + # the mean is over Mon-Wed only. + log = _days("2026-01-05", [3000, 3000, 3000]) | {"2026-01-08": [_entry(100)]} + result = _averages_report.weekly_average( + log, + schedule=_flat(2000), + today=date(2026, 1, 8), + ) + assert result.avg_kcal == 3000 + assert result.end == "2026-01-07" + assert result.band is AverageBand.VERY_OVER + + def test_monday_has_no_complete_days_this_week(self) -> None: + log = _days("2026-01-05", [3000]) + result = _averages_report.weekly_average( + log, + schedule=_flat(2000), + today=date(2026, 1, 5), + ) + assert result.elapsed_days == 0 + assert result.avg_kcal is None + assert result.band is None + + def test_previous_week_is_whole(self) -> None: + log = _days("2026-01-05", [2100, 2100, 2100, 2100, 2100, 2100, 2100]) + result = _averages_report.weekly_average( + log, + schedule=_flat(2000), + weeks_ago=1, + today=date(2026, 1, 14), + ) + assert (result.start, result.end) == ("2026-01-05", "2026-01-11") + assert result.elapsed_days == 7 + assert result.logged_days == 7 + assert result.band is AverageBand.SLIGHTLY_OVER + + def test_defaults_to_the_real_today(self) -> None: + # No explicit `today`: the call must still resolve a real date range + # rather than raising or returning an unbounded period. + result = _averages_report.weekly_average({}, schedule=_flat(2000)) + assert result.elapsed_days >= 0 + + +class TestMonthlyAverage: + def test_current_month_stops_at_yesterday(self) -> None: + log = _days("2026-03-01", [1000, 1000, 1000]) | {"2026-03-04": [_entry(9000)]} + result = _averages_report.monthly_average( + log, + schedule=_flat(2000), + today=date(2026, 3, 4), + ) + assert result.avg_kcal == 1000 + assert (result.start, result.end) == ("2026-03-01", "2026-03-03") + assert result.band is AverageBand.UNDER + + def test_first_of_the_month_has_no_complete_days(self) -> None: + result = _averages_report.monthly_average( + {}, + schedule=_flat(2000), + today=date(2026, 3, 1), + ) + assert result.elapsed_days == 0 + assert result.avg_kcal is None + + def test_previous_month_is_whole(self) -> None: + log = _days("2026-02-01", [2500, 2500]) + result = _averages_report.monthly_average( + log, + schedule=_flat(2000), + months_ago=1, + today=date(2026, 3, 10), + ) + assert (result.start, result.end) == ("2026-02-01", "2026-02-28") + assert result.elapsed_days == 28 + assert result.logged_days == 2 + assert result.band is AverageBand.VERY_OVER + + def test_defaults_to_the_real_today(self) -> None: + result = _averages_report.monthly_average({}, schedule=_flat(2000)) + assert result.elapsed_days >= 0 + + +class TestRecentPeriods: + def test_recent_weeks_are_newest_first(self) -> None: + weeks = _averages_report.recent_weeks( + {}, + schedule=_flat(2000), + count=3, + today=date(2026, 1, 14), + ) + assert [week.start for week in weeks] == [ + "2026-01-12", + "2026-01-05", + "2025-12-29", + ] + + def test_recent_months_are_newest_first(self) -> None: + months = _averages_report.recent_months( + {}, + schedule=_flat(2000), + count=3, + today=date(2026, 1, 14), + ) + assert [month.start for month in months] == [ + "2026-01-01", + "2025-12-01", + "2025-11-01", + ] + + def test_zero_count_is_empty(self) -> None: + assert ( + _averages_report.recent_weeks( + {}, + schedule=_flat(2000), + count=0, + today=date(2026, 1, 14), + ) + == () + ) + + def test_recent_weeks_defaults_to_the_real_today(self) -> None: + assert ( + len(_averages_report.recent_weeks({}, schedule=_flat(2000), count=2)) == 2 + ) + + def test_recent_months_defaults_to_the_real_today(self) -> None: + assert ( + len(_averages_report.recent_months({}, schedule=_flat(2000), count=2)) == 2 + ) diff --git a/diet_guard/tests/test_budget.py b/diet_guard/tests/test_budget.py index cf8fcc4..29fd187 100644 --- a/diet_guard/tests/test_budget.py +++ b/diet_guard/tests/test_budget.py @@ -11,17 +11,18 @@ import pytest from diet_guard import _budget from diet_guard._budget import ( - Biometrics, BudgetFileCorruptError, BudgetNotInitializedError, - budget_weight, - compute_target_budget, daily_budget, is_initialized, - mifflin_st_jeor_bmr, - protein_target_g, write_budget, ) +from diet_guard._budget_biometrics import ( + Biometrics, + compute_target_budget, + mifflin_st_jeor_bmr, +) +from diet_guard._budget_derived import budget_weight, protein_target_g if TYPE_CHECKING: from collections.abc import Callable, Iterator diff --git a/diet_guard/tests/test_budget_history.py b/diet_guard/tests/test_budget_history.py index 2f0e7d1..fb07e16 100644 --- a/diet_guard/tests/test_budget_history.py +++ b/diet_guard/tests/test_budget_history.py @@ -6,22 +6,16 @@ fixture, so every read/write here is isolated from real user data. from __future__ import annotations -from datetime import datetime, timezone -import json +from datetime import datetime -from diet_guard import _budget, _budget_history from diet_guard._budget_history import ( EPOCH_DAY, BudgetEntry, BudgetSchedule, history_from_json, history_to_json, - load_entries, - read_raw_history, - record_budget_change, seed_from_budget, upsert, - write_raw_history, ) @@ -160,129 +154,3 @@ class TestSeedFromBudget: def test_missing_timestamp_falls_back_to_the_epoch(self) -> None: seeded = seed_from_budget({"b": 2200}) assert seeded[0].edited_at.startswith("1970-01-01") - - -class TestFileIO: - """Reading, writing, and lazily seeding the history file.""" - - def test_absent_file_reads_as_none(self) -> None: - assert read_raw_history() is None - - def test_write_then_read_round_trips(self) -> None: - write_raw_history(history_to_json((_entry(EPOCH_DAY, 2200),))) - assert history_from_json(read_raw_history())[0].kcal == 2200 - - def test_corrupt_file_reads_as_none(self) -> None: - _budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) - _budget_history.BUDGET_HISTORY_FILE.write_text("not json{{{") - assert read_raw_history() is None - - def test_non_object_file_reads_as_none(self) -> None: - _budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) - _budget_history.BUDGET_HISTORY_FILE.write_text("[1, 2, 3]") - assert read_raw_history() is None - - def test_load_entries_is_a_pure_read_and_never_seeds(self) -> None: - """Seeding belongs to ``_budget``; this must not reach back into it.""" - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - assert load_entries() == () - assert read_raw_history() is None - - def test_load_entries_is_empty_without_a_budget(self) -> None: - assert load_entries() == () - - def test_seeding_never_overwrites_a_pulled_history(self) -> None: - write_raw_history(history_to_json((_entry("2026-07-26", 2000),))) - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - schedule = _budget.current_schedule(default=1) - assert [e.effective_from for e in schedule.entries] == ["2026-07-26"] - - def test_record_budget_change_defaults_to_now(self) -> None: - record_budget_change(1900) - entries = load_entries() - today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() - assert entries[-1].effective_from == today - assert entries[-1].kcal == 1900 - - -class TestEmptyHistoryDocument: - """An empty-but-present history must still seed. - - Regression guard: the seed used to be gated on the *file* being absent, - so an empty ``{"v": 1, "e": {}}`` -- which ``_sync`` could write back - after merging with a pre-feature peer -- permanently disabled seeding and - every past day silently adopted the newest budget. - """ - - def test_empty_document_still_grandfathers_on_write(self) -> None: - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - write_raw_history({"v": 1, "e": {}}) - - _budget.write_budget(2000) - - schedule = _budget.current_schedule(default=1) - assert schedule.for_day("2026-06-01") == 2200 - - def test_empty_document_still_grandfathers_on_read(self) -> None: - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - write_raw_history({"v": 1, "e": {}}) - - assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200 - - def test_current_schedule_seeds_a_missing_history(self) -> None: - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - - assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200 - assert read_raw_history() is not None - - def test_current_schedule_without_a_budget_uses_the_default(self) -> None: - assert _budget.current_schedule(default=1900).for_day("2026-06-01") == 1900 - - -class TestWriteBudgetIntegration: - """write_budget is the funnel that keeps the history correct.""" - - def test_first_write_grandfathers_the_previous_value(self) -> None: - """The load-bearing ordering test. - - A today-only assertion passes even when the seed happens *after* the - upsert; this asserts a PAST day, which is the case that breaks if the - two are ever reordered. - """ - _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) - - _budget.write_budget(2000) - - schedule = _budget.current_schedule(default=1) - assert schedule.for_day("2026-06-01") == 2200 - assert ( - schedule.for_day( - datetime.now(tz=timezone.utc).astimezone().date().isoformat(), - ) - == 2000 - ) - - def test_current_budget_still_reads_back_as_the_new_value(self) -> None: - _budget.write_budget(2000) - assert _budget.daily_budget() == 2000 - - def test_weight_is_preserved_alongside_the_history(self) -> None: - _budget.write_budget(2000, weight_kg=78.5) - assert _budget.budget_weight() == 78.5 - assert load_entries()[-1].kcal == 2000 - - def test_a_fresh_install_seeds_no_phantom_history(self) -> None: - """With no prior budget there is nothing to grandfather.""" - _budget.write_budget(2000) - entries = load_entries() - assert [e.effective_from for e in entries] == [ - datetime.now(tz=timezone.utc).astimezone().date().isoformat(), - ] - - def test_the_history_file_is_plain_readable_json(self) -> None: - _budget.write_budget(2000) - document = json.loads( - _budget_history.BUDGET_HISTORY_FILE.read_text(encoding="utf-8"), - ) - assert document["v"] == 1 - assert isinstance(document["e"], dict) diff --git a/diet_guard/tests/test_budget_history_io.py b/diet_guard/tests/test_budget_history_io.py new file mode 100644 index 0000000..7dffe6e --- /dev/null +++ b/diet_guard/tests/test_budget_history_io.py @@ -0,0 +1,152 @@ +"""Tests for _budget_history.py — the effective-from budget history. + +The history file is redirected into ``tmp_path`` by the autouse conftest +fixture, so every read/write here is isolated from real user data. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json + +from diet_guard import _budget, _budget_derived, _budget_history +from diet_guard._budget_history import ( + EPOCH_DAY, + BudgetEntry, + history_from_json, + history_to_json, + load_entries, + read_raw_history, + record_budget_change, + write_raw_history, +) + + +def _entry(day: str, kcal: int, t: str = "2026-01-01T00:00:00+00:00") -> BudgetEntry: + return BudgetEntry(effective_from=day, kcal=kcal, edited_at=t) + + +class TestFileIO: + """Reading, writing, and lazily seeding the history file.""" + + def test_absent_file_reads_as_none(self) -> None: + assert read_raw_history() is None + + def test_write_then_read_round_trips(self) -> None: + write_raw_history(history_to_json((_entry(EPOCH_DAY, 2200),))) + assert history_from_json(read_raw_history())[0].kcal == 2200 + + def test_corrupt_file_reads_as_none(self) -> None: + _budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + _budget_history.BUDGET_HISTORY_FILE.write_text("not json{{{") + assert read_raw_history() is None + + def test_non_object_file_reads_as_none(self) -> None: + _budget_history.BUDGET_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + _budget_history.BUDGET_HISTORY_FILE.write_text("[1, 2, 3]") + assert read_raw_history() is None + + def test_load_entries_is_a_pure_read_and_never_seeds(self) -> None: + """Seeding belongs to ``_budget``; this must not reach back into it.""" + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + assert load_entries() == () + assert read_raw_history() is None + + def test_load_entries_is_empty_without_a_budget(self) -> None: + assert load_entries() == () + + def test_seeding_never_overwrites_a_pulled_history(self) -> None: + write_raw_history(history_to_json((_entry("2026-07-26", 2000),))) + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + schedule = _budget.current_schedule(default=1) + assert [e.effective_from for e in schedule.entries] == ["2026-07-26"] + + def test_record_budget_change_defaults_to_now(self) -> None: + record_budget_change(1900) + entries = load_entries() + today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() + assert entries[-1].effective_from == today + assert entries[-1].kcal == 1900 + + +class TestEmptyHistoryDocument: + """An empty-but-present history must still seed. + + Regression guard: the seed used to be gated on the *file* being absent, + so an empty ``{"v": 1, "e": {}}`` -- which ``_sync`` could write back + after merging with a pre-feature peer -- permanently disabled seeding and + every past day silently adopted the newest budget. + """ + + def test_empty_document_still_grandfathers_on_write(self) -> None: + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + write_raw_history({"v": 1, "e": {}}) + + _budget.write_budget(2000) + + schedule = _budget.current_schedule(default=1) + assert schedule.for_day("2026-06-01") == 2200 + + def test_empty_document_still_grandfathers_on_read(self) -> None: + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + write_raw_history({"v": 1, "e": {}}) + + assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200 + + def test_current_schedule_seeds_a_missing_history(self) -> None: + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + + assert _budget.current_schedule(default=1).for_day("2026-06-01") == 2200 + assert read_raw_history() is not None + + def test_current_schedule_without_a_budget_uses_the_default(self) -> None: + assert _budget.current_schedule(default=1900).for_day("2026-06-01") == 1900 + + +class TestWriteBudgetIntegration: + """write_budget is the funnel that keeps the history correct.""" + + def test_first_write_grandfathers_the_previous_value(self) -> None: + """The load-bearing ordering test. + + A today-only assertion passes even when the seed happens *after* the + upsert; this asserts a PAST day, which is the case that breaks if the + two are ever reordered. + """ + _budget.write_raw_record({"v": 2, "b": 2200, "t": "2026-07-13T21:15:09+02:00"}) + + _budget.write_budget(2000) + + schedule = _budget.current_schedule(default=1) + assert schedule.for_day("2026-06-01") == 2200 + assert ( + schedule.for_day( + datetime.now(tz=timezone.utc).astimezone().date().isoformat(), + ) + == 2000 + ) + + def test_current_budget_still_reads_back_as_the_new_value(self) -> None: + _budget.write_budget(2000) + assert _budget.daily_budget() == 2000 + + def test_weight_is_preserved_alongside_the_history(self) -> None: + _budget.write_budget(2000, weight_kg=78.5) + assert _budget_derived.budget_weight() == 78.5 + assert load_entries()[-1].kcal == 2000 + + def test_a_fresh_install_seeds_no_phantom_history(self) -> None: + """With no prior budget there is nothing to grandfather.""" + _budget.write_budget(2000) + entries = load_entries() + assert [e.effective_from for e in entries] == [ + datetime.now(tz=timezone.utc).astimezone().date().isoformat(), + ] + + def test_the_history_file_is_plain_readable_json(self) -> None: + _budget.write_budget(2000) + document = json.loads( + _budget_history.BUDGET_HISTORY_FILE.read_text(encoding="utf-8"), + ) + assert document["v"] == 1 + assert isinstance(document["e"], dict) diff --git a/diet_guard/tests/test_estimator.py b/diet_guard/tests/test_estimator.py index 776ef28..66fee0d 100644 --- a/diet_guard/tests/test_estimator.py +++ b/diet_guard/tests/test_estimator.py @@ -10,16 +10,10 @@ from unittest.mock import MagicMock, patch import requests -from diet_guard import _estimator +from diet_guard import _estimator, _estimator_off from diet_guard._constants import DEFAULT_PORTION_GRAMS -from diet_guard._estimator import ( - Nutrition, - estimate, - estimate_off, - manual, - off_candidates, - scale_nutrition, -) +from diet_guard._estimator import Nutrition, manual, scale_nutrition +from diet_guard._estimator_off import estimate, estimate_off, off_candidates _GOOD = { "product_name": "Big Mac", @@ -38,7 +32,7 @@ def _patch_get(payload: object) -> object: response = MagicMock() response.raise_for_status = MagicMock() response.json = MagicMock(return_value=payload) - return patch.object(_estimator.requests, "get", return_value=response) + return patch.object(_estimator_off.requests, "get", return_value=response) def _hits(*products: object) -> dict[str, object]: @@ -176,7 +170,7 @@ class TestOffCandidates: def test_request_exception_returns_empty(self) -> None: """A network failure degrades to an empty candidate list.""" with patch.object( - _estimator.requests, + _estimator_off.requests, "get", side_effect=requests.RequestException("boom"), ): diff --git a/diet_guard/tests/test_foodbank.py b/diet_guard/tests/test_foodbank.py index 293c95d..7f8970a 100644 --- a/diet_guard/tests/test_foodbank.py +++ b/diet_guard/tests/test_foodbank.py @@ -7,12 +7,11 @@ fixture, so every read/write here is isolated from real user data. from __future__ import annotations import json -from pathlib import Path -from unittest.mock import patch from diet_guard import _foodbank from diet_guard._estimator import Nutrition -from diet_guard._foodbank import lookup_food, remember_food, search_foods +from diet_guard._foodbank import remember_food +from diet_guard._foodbank_search import lookup_food, search_foods _NUT = Nutrition( kcal=250, @@ -114,198 +113,3 @@ class TestSearch: _write_raw({"applekey": {"kcal": 50, "count": 1}}) names = [name for name, _ in search_foods("")] assert names == ["applekey"] - - -class TestCorruptQuarantine: - """A corrupt bank is moved aside, not re-warned about or overwritten.""" - - def test_corrupt_file_is_moved_aside(self) -> None: - """Reading a corrupt bank quarantines it and returns empty.""" - _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") - assert _foodbank._read_bank() == {} - assert not _foodbank.FOOD_BANK_FILE.exists() - backups = list( - _foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"), - ) - assert len(backups) == 1 - assert backups[0].read_text(encoding="utf-8") == "{ broken" - - def test_subsequent_reads_silent_and_empty(self) -> None: - """After quarantine the next reads find no file (no warning flood).""" - _foodbank.FOOD_BANK_FILE.write_text("nope", encoding="utf-8") - assert _foodbank._read_bank() == {} - assert _foodbank._read_bank() == {} - assert _foodbank._read_bank() == {} - - def test_corrupt_then_remember_starts_fresh(self) -> None: - """A new entry after corruption writes a fresh bank, losing nothing.""" - _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") - remember_food("eggs", _NUT) - assert lookup_food("eggs") is not None - assert list(_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*")) - - def test_rename_failure_is_handled(self) -> None: - """If the corrupt file cannot be moved, the read still returns empty.""" - _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") - with patch.object(Path, "rename", side_effect=OSError("locked")): - assert _foodbank._read_bank() == {} - - -class TestRebuildFoodBank: - """Replaying a full log into a fresh bank, mirroring the Dart port.""" - - def test_rebuilds_a_simple_food_entry(self) -> None: - log = { - "2026-06-22": [ - { - "id": "a", - "time": "2026-06-22T08:00:00+02:00", - "desc": "toast", - "kcal": 150.0, - "protein_g": 5.0, - "carbs_g": 20.0, - "fat_g": 3.0, - "grams": 50.0, - "source": "manual", - }, - ], - } - bank = _foodbank.rebuild_food_bank(log) - assert lookup_food("toast") is not None - assert bank["toast"]["count"] == 1 - - def test_skips_tombstoned_entries(self) -> None: - log = { - "2026-06-22": [ - { - "id": "a", - "time": "2026-06-22T08:00:00+02:00", - "desc": "toast", - "kcal": 150.0, - "protein_g": 5.0, - "carbs_g": 20.0, - "fat_g": 3.0, - "grams": 50.0, - "source": "manual", - "deleted": True, - }, - ], - } - bank = _foodbank.rebuild_food_bank(log) - assert bank == {} - - def test_banks_each_component_and_the_composite(self) -> None: - log = { - "2026-06-22": [ - { - "id": "a", - "time": "2026-06-22T20:00:00+02:00", - "desc": "dinner", - "kcal": 465.0, - "protein_g": 37.0, - "carbs_g": 66.0, - "fat_g": 5.5, - "grams": 300.0, - "source": "meal", - "components": [ - { - "name": "rice", - "kcal": 300.0, - "protein_g": 6.0, - "carbs_g": 66.0, - "fat_g": 1.5, - "grams": 150.0, - }, - { - "name": "chicken", - "kcal": 165.0, - "protein_g": 31.0, - "carbs_g": 0.0, - "fat_g": 4.0, - "grams": 150.0, - }, - ], - }, - ], - } - _foodbank.rebuild_food_bank(log) - assert lookup_food("rice") is not None - assert lookup_food("chicken") is not None - composite = lookup_food("dinner") - assert composite is not None - assert composite.kcal == 465.0 - - def test_replays_in_time_then_id_order_so_count_and_latest_macros_agree( - self, - ) -> None: - log = { - "2026-06-22": [ - { - "id": "b", - "time": "2026-06-22T12:00:00+02:00", - "desc": "toast", - "kcal": 999.0, - "protein_g": 0.0, - "carbs_g": 0.0, - "fat_g": 0.0, - "grams": 0.0, - "source": "manual", - }, - { - "id": "a", - "time": "2026-06-22T08:00:00+02:00", - "desc": "toast", - "kcal": 150.0, - "protein_g": 5.0, - "carbs_g": 20.0, - "fat_g": 3.0, - "grams": 50.0, - "source": "manual", - }, - ], - } - bank = _foodbank.rebuild_food_bank(log) - # Replayed oldest-first (08:00 then 12:00) regardless of list order, - # so the 12:00 entry's macros are the ones that survive. - assert bank["toast"]["kcal"] == 999.0 - assert bank["toast"]["count"] == 2 - - def test_persists_to_disk(self) -> None: - log = { - "2026-06-22": [ - { - "id": "a", - "time": "2026-06-22T08:00:00+02:00", - "desc": "toast", - "kcal": 150.0, - "protein_g": 5.0, - "carbs_g": 20.0, - "fat_g": 3.0, - "grams": 50.0, - "source": "manual", - }, - ], - } - _foodbank.rebuild_food_bank(log) - # A fresh read (not the in-memory return value) must also see it. - assert lookup_food("toast") is not None - - def test_ignores_a_non_dict_component(self) -> None: - log = { - "2026-06-22": [ - { - "id": "a", - "time": "2026-06-22T08:00:00+02:00", - "desc": "dinner", - "kcal": 100.0, - "protein_g": 1.0, - "carbs_g": 1.0, - "fat_g": 1.0, - "grams": 100.0, - "source": "meal", - "components": ["not-a-dict"], - }, - ], - } - _foodbank.rebuild_food_bank(log) - assert lookup_food("dinner") is not None diff --git a/diet_guard/tests/test_foodbank_manual.py b/diet_guard/tests/test_foodbank_manual.py index a5cbdf3..a050f29 100644 --- a/diet_guard/tests/test_foodbank_manual.py +++ b/diet_guard/tests/test_foodbank_manual.py @@ -8,15 +8,16 @@ from __future__ import annotations import json -from diet_guard import _foodbank, _foodbank_manual +from diet_guard import _foodbank_manual, _foodbank_rebuild from diet_guard._estimator import Nutrition -from diet_guard._foodbank import lookup_food, remember_food, search_foods +from diet_guard._foodbank import remember_food from diet_guard._foodbank_manual import ( add_manual_entry, read_manual_bank, record_edit_time, write_manual_bank, ) +from diet_guard._foodbank_search import lookup_food, search_foods _SKYR = { "desc": "Skyr", @@ -120,5 +121,5 @@ class TestVisibleToTheBank: """The derived bank is rewritten on every log write; curated entries live in their own file precisely so that cannot wipe them.""" add_manual_entry("Skyr", dict(_SKYR)) - _foodbank.rebuild_food_bank({}) + _foodbank_rebuild.rebuild_food_bank({}) assert lookup_food("Skyr") is not None diff --git a/diet_guard/tests/test_foodbank_rebuild.py b/diet_guard/tests/test_foodbank_rebuild.py new file mode 100644 index 0000000..a732712 --- /dev/null +++ b/diet_guard/tests/test_foodbank_rebuild.py @@ -0,0 +1,219 @@ +"""Tests for _foodbank.py — the local corpus of previously logged foods. + +The food-bank file is redirected into ``tmp_path`` by the autouse conftest +fixture, so every read/write here is isolated from real user data. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from diet_guard import _foodbank, _foodbank_rebuild +from diet_guard._estimator import Nutrition +from diet_guard._foodbank import remember_food +from diet_guard._foodbank_search import lookup_food + +_NUT = Nutrition( + kcal=250, + protein_g=12, + carbs_g=30, + fat_g=10, + grams=200, + source="manual", +) + + +class TestRebuildFoodBank: + """Replaying a full log into a fresh bank, mirroring the Dart port.""" + + def test_rebuilds_a_simple_food_entry(self) -> None: + log = { + "2026-06-22": [ + { + "id": "a", + "time": "2026-06-22T08:00:00+02:00", + "desc": "toast", + "kcal": 150.0, + "protein_g": 5.0, + "carbs_g": 20.0, + "fat_g": 3.0, + "grams": 50.0, + "source": "manual", + }, + ], + } + bank = _foodbank_rebuild.rebuild_food_bank(log) + assert lookup_food("toast") is not None + assert bank["toast"]["count"] == 1 + + def test_skips_tombstoned_entries(self) -> None: + log = { + "2026-06-22": [ + { + "id": "a", + "time": "2026-06-22T08:00:00+02:00", + "desc": "toast", + "kcal": 150.0, + "protein_g": 5.0, + "carbs_g": 20.0, + "fat_g": 3.0, + "grams": 50.0, + "source": "manual", + "deleted": True, + }, + ], + } + bank = _foodbank_rebuild.rebuild_food_bank(log) + assert bank == {} + + def test_banks_each_component_and_the_composite(self) -> None: + log = { + "2026-06-22": [ + { + "id": "a", + "time": "2026-06-22T20:00:00+02:00", + "desc": "dinner", + "kcal": 465.0, + "protein_g": 37.0, + "carbs_g": 66.0, + "fat_g": 5.5, + "grams": 300.0, + "source": "meal", + "components": [ + { + "name": "rice", + "kcal": 300.0, + "protein_g": 6.0, + "carbs_g": 66.0, + "fat_g": 1.5, + "grams": 150.0, + }, + { + "name": "chicken", + "kcal": 165.0, + "protein_g": 31.0, + "carbs_g": 0.0, + "fat_g": 4.0, + "grams": 150.0, + }, + ], + }, + ], + } + _foodbank_rebuild.rebuild_food_bank(log) + assert lookup_food("rice") is not None + assert lookup_food("chicken") is not None + composite = lookup_food("dinner") + assert composite is not None + assert composite.kcal == 465.0 + + def test_replays_in_time_then_id_order_so_count_and_latest_macros_agree( + self, + ) -> None: + log = { + "2026-06-22": [ + { + "id": "b", + "time": "2026-06-22T12:00:00+02:00", + "desc": "toast", + "kcal": 999.0, + "protein_g": 0.0, + "carbs_g": 0.0, + "fat_g": 0.0, + "grams": 0.0, + "source": "manual", + }, + { + "id": "a", + "time": "2026-06-22T08:00:00+02:00", + "desc": "toast", + "kcal": 150.0, + "protein_g": 5.0, + "carbs_g": 20.0, + "fat_g": 3.0, + "grams": 50.0, + "source": "manual", + }, + ], + } + bank = _foodbank_rebuild.rebuild_food_bank(log) + # Replayed oldest-first (08:00 then 12:00) regardless of list order, + # so the 12:00 entry's macros are the ones that survive. + assert bank["toast"]["kcal"] == 999.0 + assert bank["toast"]["count"] == 2 + + def test_persists_to_disk(self) -> None: + log = { + "2026-06-22": [ + { + "id": "a", + "time": "2026-06-22T08:00:00+02:00", + "desc": "toast", + "kcal": 150.0, + "protein_g": 5.0, + "carbs_g": 20.0, + "fat_g": 3.0, + "grams": 50.0, + "source": "manual", + }, + ], + } + _foodbank_rebuild.rebuild_food_bank(log) + # A fresh read (not the in-memory return value) must also see it. + assert lookup_food("toast") is not None + + def test_ignores_a_non_dict_component(self) -> None: + log = { + "2026-06-22": [ + { + "id": "a", + "time": "2026-06-22T08:00:00+02:00", + "desc": "dinner", + "kcal": 100.0, + "protein_g": 1.0, + "carbs_g": 1.0, + "fat_g": 1.0, + "grams": 100.0, + "source": "meal", + "components": ["not-a-dict"], + }, + ], + } + _foodbank_rebuild.rebuild_food_bank(log) + assert lookup_food("dinner") is not None + + +class TestCorruptQuarantine: + """A corrupt bank is moved aside, not re-warned about or overwritten.""" + + def test_corrupt_file_is_moved_aside(self) -> None: + """Reading a corrupt bank quarantines it and returns empty.""" + _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") + assert _foodbank._read_bank() == {} + assert not _foodbank.FOOD_BANK_FILE.exists() + backups = list( + _foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*"), + ) + assert len(backups) == 1 + assert backups[0].read_text(encoding="utf-8") == "{ broken" + + def test_subsequent_reads_silent_and_empty(self) -> None: + """After quarantine the next reads find no file (no warning flood).""" + _foodbank.FOOD_BANK_FILE.write_text("nope", encoding="utf-8") + assert _foodbank._read_bank() == {} + assert _foodbank._read_bank() == {} + assert _foodbank._read_bank() == {} + + def test_corrupt_then_remember_starts_fresh(self) -> None: + """A new entry after corruption writes a fresh bank, losing nothing.""" + _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") + remember_food("eggs", _NUT) + assert lookup_food("eggs") is not None + assert list(_foodbank.FOOD_BANK_FILE.parent.glob("food_bank.json.corrupt-*")) + + def test_rename_failure_is_handled(self) -> None: + """If the corrupt file cannot be moved, the read still returns empty.""" + _foodbank.FOOD_BANK_FILE.write_text("{ broken", encoding="utf-8") + with patch.object(Path, "rename", side_effect=OSError("locked")): + assert _foodbank._read_bank() == {} diff --git a/diet_guard/tests/test_gatelock_calendar.py b/diet_guard/tests/test_gatelock_calendar.py index 5720a83..317eb19 100644 --- a/diet_guard/tests/test_gatelock_calendar.py +++ b/diet_guard/tests/test_gatelock_calendar.py @@ -7,7 +7,8 @@ from typing import TYPE_CHECKING from unittest.mock import patch from diet_guard import _budget -from diet_guard._budget import budget_weight, daily_budget, write_budget +from diet_guard._budget import daily_budget, write_budget +from diet_guard._budget_derived import budget_weight from diet_guard._state import log_meal from diet_guard.tests.conftest import _nutrition diff --git a/diet_guard/tests/test_state_redirect.py b/diet_guard/tests/test_state_redirect.py index eccd799..615edb4 100644 --- a/diet_guard/tests/test_state_redirect.py +++ b/diet_guard/tests/test_state_redirect.py @@ -20,53 +20,84 @@ from __future__ import annotations import ast import pathlib -from diet_guard import _state -from diet_guard._constants import FOOD_LOG_FILE as REAL_FOOD_LOG_FILE +from diet_guard import _budget, _budget_history, _state +from diet_guard._constants import ( + BUDGET_FILE as REAL_BUDGET_FILE, +) +from diet_guard._constants import ( + BUDGET_HISTORY_FILE as REAL_BUDGET_HISTORY_FILE, +) +from diet_guard._constants import ( + FOOD_LOG_FILE as REAL_FOOD_LOG_FILE, +) _PACKAGE_DIR = pathlib.Path(__file__).resolve().parent.parent -#: The one module allowed to open the food log. Everything else must go -#: through its ``_read_raw_log`` / ``_write_log``. -_LOG_FILE_OWNER = "_state" +#: Each on-disk state path conftest redirects, mapped to the single package +#: module allowed to name it. Anything else naming one means the redirect no +#: longer covers every reader. +_REDIRECTED_CONSTANTS = { + "FOOD_LOG_FILE": "_state", + "BUDGET_FILE": "_budget", + "BUDGET_HISTORY_FILE": "_budget_history", + "FOOD_BANK_FILE": "_foodbank", + "MANUAL_BANK_FILE": "_foodbank_manual", +} -def _names_food_log_file(path: pathlib.Path) -> bool: - """Whether ``path`` mentions ``FOOD_LOG_FILE`` at all.""" +def _names(path: pathlib.Path, constant: str) -> bool: + """Whether ``path`` mentions ``constant`` at all.""" tree = ast.parse(path.read_text(encoding="utf-8"), str(path)) return any( - (isinstance(node, ast.Name) and node.id == "FOOD_LOG_FILE") - or (isinstance(node, ast.Attribute) and node.attr == "FOOD_LOG_FILE") + (isinstance(node, ast.Name) and node.id == constant) + or (isinstance(node, ast.Attribute) and node.attr == constant) for node in ast.walk(tree) ) -def test_only_state_names_the_log_file() -> None: - """``FOOD_LOG_FILE`` is referenced from exactly one package module. +def test_only_owners_name_redirected_paths() -> None: + """Each redirected state path is referenced from exactly one module. - Fails closed: a split that moves a log reader into a sibling shows up here - as a named module, instead of as a test run quietly writing to the real - food log. + Fails closed: a split that moves a reader of one of these constants into a + sibling shows up here as a named module, instead of as a test run quietly + writing to the real ``~/.local/share/diet_guard``. """ - namers = sorted( - path.stem for path in _PACKAGE_DIR.glob("*.py") if _names_food_log_file(path) - ) - assert set(namers) <= {_LOG_FILE_OWNER, "_constants"}, ( - "FOOD_LOG_FILE must be named only by _constants (its definition) and " - f"_state (the sole reader conftest patches); found: {namers}" - ) + for constant, owner in _REDIRECTED_CONSTANTS.items(): + namers = sorted( + path.stem for path in _PACKAGE_DIR.glob("*.py") if _names(path, constant) + ) + assert set(namers) <= {owner, "_constants"}, ( + f"{constant} must be named only by _constants (its definition) " + f"and {owner} (the sole reader conftest patches); found: {namers}" + ) -def test_redirect_moves_writes_off_the_live_log() -> None: - """Under the conftest redirect, a real write misses the live path. +def test_redirect_moves_writes_off_the_live_paths() -> None: + """Under the conftest redirect, real writes miss the live paths. - The complement of the static check: proves the patched constant is the one - the write path actually resolves, not merely that the name is in the right - file. + The complement of the static check: proves each patched constant is the + one the write path actually resolves, not merely that the name sits in the + right file. """ - assert _state.FOOD_LOG_FILE != REAL_FOOD_LOG_FILE, ( - "conftest._isolate_state is not redirecting diet_guard._state." - "FOOD_LOG_FILE -- tests would write to the real food log" - ) + live = { + "_state.FOOD_LOG_FILE": (_state.FOOD_LOG_FILE, REAL_FOOD_LOG_FILE), + "_budget.BUDGET_FILE": (_budget.BUDGET_FILE, REAL_BUDGET_FILE), + "_budget_history.BUDGET_HISTORY_FILE": ( + _budget_history.BUDGET_HISTORY_FILE, + REAL_BUDGET_HISTORY_FILE, + ), + } + for name, (patched, real) in live.items(): + assert patched != real, ( + f"conftest._isolate_state is not redirecting {name} -- " + "tests would write to real user data" + ) _state._write_log({"2026-06-22": []}) assert _state.FOOD_LOG_FILE.exists(), "write did not land on the redirect" + + _budget.write_budget(2000) + assert _budget.BUDGET_FILE.exists(), "budget write did not land on the redirect" + assert _budget_history.BUDGET_HISTORY_FILE.exists(), ( + "budget-history write did not land on the redirect" + ) diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index 16fae66..b0811ba 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -14,7 +14,7 @@ import pytest from diet_guard import _sync, _sync_client from diet_guard._device import device_id -from diet_guard._foodbank import lookup_food +from diet_guard._foodbank_search import lookup_food from diet_guard._state import load_log, log_meal from diet_guard.tests._sync_fakes import _mock_client, _nutrition, _write_token diff --git a/diet_guard/tests/test_sync_banks.py b/diet_guard/tests/test_sync_banks.py index 478478b..0a1c3f8 100644 --- a/diet_guard/tests/test_sync_banks.py +++ b/diet_guard/tests/test_sync_banks.py @@ -14,8 +14,9 @@ from unittest.mock import patch from diet_guard import _sync, _sync_client from diet_guard._device import device_id from diet_guard._estimator import Nutrition -from diet_guard._foodbank import lookup_food, read_food_bank +from diet_guard._foodbank import read_food_bank from diet_guard._foodbank_manual import add_manual_entry, read_manual_bank +from diet_guard._foodbank_search import lookup_food from diet_guard._state import log_meal from diet_guard.sync_merge import food_bank_to_log, manual_bank_to_log from diet_guard.tests._sync_fakes import _mock_client, _write_token diff --git a/diet_guard/tests/test_sync_budget.py b/diet_guard/tests/test_sync_budget.py index 6c21d0a..6aa34d3 100644 --- a/diet_guard/tests/test_sync_budget.py +++ b/diet_guard/tests/test_sync_budget.py @@ -10,7 +10,8 @@ from __future__ import annotations from unittest.mock import patch from diet_guard import _sync, _sync_client -from diet_guard._budget import budget_weight, 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_history import load_entries from diet_guard._device import device_id from diet_guard.tests._sync_fakes import (