mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 12:03:08 +02:00
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
351 lines
12 KiB
Python
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
|