mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 19:33:55 +01:00
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
229 lines
8.2 KiB
Python
229 lines
8.2 KiB
Python
"""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, _averages_periods
|
|
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 TestBandFor:
|
|
def test_exactly_at_budget_is_under(self) -> None:
|
|
assert _averages.band_for(2000, 2000) is AverageBand.UNDER
|
|
|
|
def test_below_budget_is_under(self) -> None:
|
|
assert _averages.band_for(1500, 2000) is AverageBand.UNDER
|
|
|
|
def test_one_over_is_slightly_over(self) -> None:
|
|
assert _averages.band_for(2001, 2000) is AverageBand.SLIGHTLY_OVER
|
|
|
|
def test_exactly_at_ceiling_is_slightly_over(self) -> None:
|
|
# 2400 sits right at the 120% ceiling for a 2000 budget.
|
|
assert _averages.band_for(2400, 2000) is AverageBand.SLIGHTLY_OVER
|
|
|
|
def test_just_past_ceiling_is_very_over(self) -> None:
|
|
assert _averages.band_for(2400.01, 2000) is AverageBand.VERY_OVER
|
|
|
|
def test_way_past_ceiling_is_very_over(self) -> None:
|
|
assert _averages.band_for(4000, 2000) is AverageBand.VERY_OVER
|
|
|
|
def test_boundary_matches_the_calendar_day_boundary(self) -> None:
|
|
# The averages line and the calendar must never disagree about "over":
|
|
# a period averaging exactly one day's YELLOW total is SLIGHTLY_OVER.
|
|
ceiling = _averages.OVER_BUDGET_YELLOW_CEILING
|
|
assert _averages.band_for(2000 * ceiling, 2000) is AverageBand.SLIGHTLY_OVER
|
|
|
|
|
|
class TestBandLabel:
|
|
def test_none_is_no_data(self) -> None:
|
|
assert _averages.band_label(None) == "no data"
|
|
|
|
def test_under(self) -> None:
|
|
assert _averages.band_label(AverageBand.UNDER) == "under"
|
|
|
|
def test_slightly_over(self) -> None:
|
|
assert _averages.band_label(AverageBand.SLIGHTLY_OVER) == "slightly over"
|
|
|
|
def test_very_over(self) -> None:
|
|
assert _averages.band_label(AverageBand.VERY_OVER) == "very over"
|
|
|
|
|
|
class TestPeriodBounds:
|
|
def test_week_starts_monday(self) -> None:
|
|
# 2026-01-07 is a Wednesday.
|
|
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_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_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_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_periods.month_bounds(date(2024, 2, 14))
|
|
assert end == date(2024, 2, 29)
|
|
|
|
def test_last_complete_day_is_yesterday(self) -> None:
|
|
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_periods._shift_months(date(2026, 5, 20), 0) == date(2026, 5, 1)
|
|
|
|
def test_one_back(self) -> None:
|
|
assert _averages_periods._shift_months(date(2026, 5, 20), 1) == date(2026, 4, 1)
|
|
|
|
def test_wraps_the_year(self) -> None:
|
|
assert _averages_periods._shift_months(date(2026, 1, 20), 1) == date(
|
|
2025, 12, 1
|
|
)
|
|
|
|
def test_wraps_several_years(self) -> None:
|
|
assert _averages_periods._shift_months(date(2026, 3, 20), 27) == date(
|
|
2023, 12, 1
|
|
)
|
|
|
|
|
|
class TestPeriodAverage:
|
|
def test_averages_over_logged_days_only(self) -> None:
|
|
# Three logged days (2000, 3000, 4000) plus two gaps: the mean is
|
|
# 3000, NOT 1800 -- a day you forgot to log is not a zero-kcal day.
|
|
log = {
|
|
"2026-01-05": [_entry(2000)],
|
|
"2026-01-06": [_entry(3000)],
|
|
"2026-01-09": [_entry(4000)],
|
|
}
|
|
result = _averages.period_average(
|
|
log,
|
|
schedule=_flat(2500),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 9),
|
|
)
|
|
assert result.avg_kcal == 3000
|
|
assert result.logged_days == 3
|
|
assert result.elapsed_days == 5
|
|
|
|
def test_sums_multiple_entries_in_a_day(self) -> None:
|
|
log = {"2026-01-05": [_entry(1200), _entry(800)]}
|
|
result = _averages.period_average(
|
|
log,
|
|
schedule=_flat(2000),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 5),
|
|
)
|
|
assert result.avg_kcal == 2000
|
|
assert result.band is AverageBand.UNDER
|
|
|
|
def test_empty_period_has_no_average(self) -> None:
|
|
result = _averages.period_average(
|
|
{},
|
|
schedule=_flat(2000),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 11),
|
|
)
|
|
assert result.avg_kcal is None
|
|
assert result.avg_budget is None
|
|
assert result.band is None
|
|
assert result.logged_days == 0
|
|
assert result.elapsed_days == 7
|
|
|
|
def test_end_before_start_is_zero_elapsed_not_negative(self) -> None:
|
|
result = _averages.period_average(
|
|
{},
|
|
schedule=_flat(2000),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 4),
|
|
)
|
|
assert result.elapsed_days == 0
|
|
assert result.avg_kcal is None
|
|
|
|
def test_days_outside_the_range_are_ignored(self) -> None:
|
|
log = {"2026-01-04": [_entry(9000)], "2026-01-05": [_entry(1000)]}
|
|
result = _averages.period_average(
|
|
log,
|
|
schedule=_flat(2000),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 5),
|
|
)
|
|
assert result.avg_kcal == 1000
|
|
|
|
def test_budget_is_averaged_per_logged_day_not_taken_from_today(self) -> None:
|
|
# The budget rose to 3000 partway through: a period spanning the change
|
|
# is judged against the mean of the two, not against either endpoint.
|
|
schedule = BudgetSchedule(
|
|
(
|
|
BudgetEntry(EPOCH_DAY, 1000, "1970-01-01T00:00:00+00:00"),
|
|
BudgetEntry("2026-01-06", 3000, "2026-01-06T00:00:00+00:00"),
|
|
),
|
|
default=1000,
|
|
)
|
|
log = {"2026-01-05": [_entry(2000)], "2026-01-06": [_entry(2000)]}
|
|
result = _averages.period_average(
|
|
log,
|
|
schedule=schedule,
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 6),
|
|
)
|
|
assert result.avg_budget == 2000
|
|
assert result.band is AverageBand.UNDER
|
|
|
|
def test_unlogged_days_do_not_pull_the_budget_average(self) -> None:
|
|
# Only the logged day counts on both sides of the comparison.
|
|
schedule = BudgetSchedule(
|
|
(
|
|
BudgetEntry(EPOCH_DAY, 1000, "1970-01-01T00:00:00+00:00"),
|
|
BudgetEntry("2026-01-06", 3000, "2026-01-06T00:00:00+00:00"),
|
|
),
|
|
default=1000,
|
|
)
|
|
log = {"2026-01-06": [_entry(2000)]}
|
|
result = _averages.period_average(
|
|
log,
|
|
schedule=schedule,
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 6),
|
|
)
|
|
assert result.avg_budget == 3000
|
|
|
|
def test_reports_its_own_range(self) -> None:
|
|
result = _averages.period_average(
|
|
{},
|
|
schedule=_flat(2000),
|
|
start=date(2026, 1, 5),
|
|
end=date(2026, 1, 11),
|
|
)
|
|
assert (result.start, result.end) == ("2026-01-05", "2026-01-11")
|