diet-guard/diet_guard/tests/test_foodbank.py
Krzysztof kuhy Rudnicki e5b80fd610 Add cross-device log sync (Python half of Milestone 3)
Pulls every other device's pushed log from GitHub-backed dumb storage,
merges it with the local log, and pushes this device's own merged copy
back -- the PC half of the diet-guard-app sync plan.

- _sync_merge.py: pure union-by-id merge, tombstone always wins, legacy
  (time, desc) dedup for pre-id entries. Commutative and idempotent.
- _sync_github.py: minimal GitHub Contents API client (list/get/put),
  distinguishing a 404 on an unused path from the repo itself being
  unreachable.
- _sync.py: orchestration -- pull, merge, re-sign every persisted entry
  regardless of origin, write, rebuild the food bank, push. Re-signing
  unconditionally is load-bearing: an unsigned phone-origin entry would
  otherwise be silently dropped on the very next read once a machine
  holds the shared HMAC key.
- _foodbank.rebuild_food_bank(): the "replay a full log into a fresh
  bank" entrypoint the Python side was missing (the Dart port already
  had its equivalent). Backs sync's bank-rebuild step.
- New diet-guard-sync.service/.timer (15-minute cadence, headless, a
  separate unit from the gate so a held lock can't stall sync) and a
  new install.sh step to install them.
- Created the private kuhyx/diet-guard-sync GitHub repo for storage.

Incidental to this feature: adding the `sync` subcommand pushed _cli.py
past the repo's 500-line cap, so `gate`'s CLI glue moved out alongside
sync's into _cli_gate.py/_cli_sync.py -- same split pattern already used
for the gate window logic itself, not a sync-specific design choice.

338 tests, 100% branch coverage. Verified importing and running cleanly
under /usr/bin/python (the production interpreter), not just the dev
venv -- the gap that caused the earlier 3-day outage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 19:36:27 +02:00

351 lines
12 KiB
Python

"""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
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,
remember_meal,
search_foods,
)
from diet_guard._meal import MealItem
_NUT = Nutrition(
kcal=250,
protein_g=12,
carbs_g=30,
fat_g=10,
grams=200,
source="manual",
)
def _write_raw(bank: object) -> None:
"""Write an arbitrary object as the bank file (for defensive-read tests)."""
_foodbank.FOOD_BANK_FILE.write_text(json.dumps(bank), encoding="utf-8")
class TestRememberAndLookup:
"""Round-tripping foods through the bank."""
def test_blank_description_ignored(self) -> None:
"""A blank name is not stored."""
remember_food(" ", _NUT)
assert lookup_food(" ") is None
def test_roundtrip_case_insensitive(self) -> None:
"""A remembered food is found regardless of case."""
remember_food("Big Mac", _NUT)
found = lookup_food("big mac")
assert found is not None
assert found.kcal == 250
assert found.source == "food bank"
def test_lookup_miss(self) -> None:
"""An unknown food looks up to None."""
assert lookup_food("nope") is None
def test_recording_twice_bumps_count(self) -> None:
"""Re-logging a food increments its use count (raises its ranking)."""
remember_food("oats", _NUT)
remember_food("oats", _NUT)
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert bank["oats"]["count"] == 2
class TestReadDefensive:
"""The bank read tolerates a missing or corrupt file."""
def test_missing_file(self) -> None:
"""No file yet -> empty results."""
assert search_foods("anything") == []
def test_corrupt_json(self) -> None:
"""Unparsable content -> empty bank."""
_foodbank.FOOD_BANK_FILE.write_text("not json", encoding="utf-8")
assert search_foods("x") == []
def test_top_level_not_dict(self) -> None:
"""A non-object top level -> empty bank."""
_write_raw([1, 2, 3])
assert search_foods("x") == []
def test_non_dict_records_filtered(self) -> None:
"""Records that are not objects are dropped on read."""
_write_raw({"good": {"desc": "good", "kcal": 5, "count": 1}, "bad": 123})
names = [name for name, _ in search_foods("")]
assert names == ["good"]
class TestSearch:
"""Ranked autocomplete search."""
def test_empty_query_ranks_by_count(self) -> None:
"""An empty query returns all foods, most-logged first."""
remember_food("rare", _NUT)
remember_food("common", _NUT)
remember_food("common", _NUT)
names = [name for name, _ in search_foods("")]
assert names[0] == "common"
def test_substring_match(self) -> None:
"""A substring of a stored name matches it."""
remember_food("chicken breast", _NUT)
names = [name for name, _ in search_foods("breast")]
assert "chicken breast" in names
def test_typo_within_threshold(self) -> None:
"""A close typo still matches via the fuzzy scorer."""
remember_food("chicken", _NUT)
names = [name for name, _ in search_foods("chiken")]
assert "chicken" in names
def test_below_threshold_filtered(self) -> None:
"""A wildly different query returns nothing."""
remember_food("chicken", _NUT)
assert search_foods("xylophone") == []
def test_display_name_falls_back_to_key(self) -> None:
"""A record with no usable desc displays under its key."""
_write_raw({"applekey": {"kcal": 50, "count": 1}})
names = [name for name, _ in search_foods("")]
assert names == ["applekey"]
class TestRememberMeal:
"""Banking a composite meal and its components."""
def test_banks_each_item_and_the_composite(self) -> None:
"""Every component and the summed meal land in the bank."""
items = [
MealItem("salad", Nutrition(80, 2, 8, 5, 120, "manual")),
MealItem("chicken", Nutrition(330, 62, 0, 7, 200, "manual")),
]
total = remember_meal("dinner", items)
assert total.kcal == 410
assert lookup_food("salad") is not None
assert lookup_food("chicken") is not None
dinner = lookup_food("dinner")
assert dinner is not None
assert dinner.kcal == 410
def test_composite_records_components(self) -> None:
"""The meal entry carries its component names for later use."""
item = MealItem("rice", Nutrition(260, 5, 56, 1, 180, "manual"))
remember_meal("bowl", [item])
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert bank["bowl"]["components"] == ["rice"]
def test_blank_name_banks_items_only(self) -> None:
"""A blank meal name still banks items but stores no empty composite."""
item = MealItem("toast", Nutrition(120, 4, 20, 2, 40, "manual"))
remember_meal(" ", [item])
assert lookup_food("toast") is not None
bank = json.loads(_foodbank.FOOD_BANK_FILE.read_text(encoding="utf-8"))
assert list(bank) == ["toast"]
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