diff --git a/README.md b/README.md index 8f65821..4957f17 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,11 @@ The API uses two protocols: Custom foods and recipes are both saved through `POST /api/v2/add_food`, which upserts: a food sent with its existing id is edited in place, and diary entries that use it reflect the edit. For a recipe, the server derives the nutrients and the "full recipe" weight from the `ingredients` list on -every save, so the client sends none. No hard-delete endpoint for foods is known, so +every save, so the client sends none, and reads both back to check them. The server counts an +ingredient's grams toward the batch only if all of the food's measures are type `Weight` (what the +web client saves for a serving with a gram weight); with an `Atomic` one, its nutrients are added but +its grams aren't, so the batch weight comes out short. Custom foods are therefore saved with +`Weight` servings, and recipes refuse other ingredients. No hard-delete endpoint for foods is known, so `delete_custom_food` sets `retired` instead (the food leaves search after a few seconds but stays readable by id, and diary entries that use it are kept). Retired foods can't be edited. Serving names are sent exactly as given with an explicit quantity, diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index c932176..b9d7169 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -86,6 +86,9 @@ _FULL_RECIPE_MEASURE = "full recipe" _WEIGHT_UNIT_MEASURES = frozenset({"g", "oz"}) _RECIPE_FIXED_MEASURES = _WEIGHT_UNIT_MEASURES | {_FULL_RECIPE_MEASURE} +# The web client saves a serving with a gram weight as "Weight"; "Atomic" is a +# count unit (a supplement "tablet"). See _check_recipe_ingredient. +_WEIGHED_MEASURE = "Weight" # Recipe serving weights are sent rounded to this many decimals, and read back # within _GRAMS_TOL: a batch that doesn't divide evenly (4561 g / 6) would # otherwise send an unbounded float. @@ -678,7 +681,9 @@ def create_custom_food( are normalized to per-100g, since Cronometer stores all nutrient data that way. A serving_name that leads with a quantity ("2 cookies") is refused. The saved serving is read back and re-sent once if the server - re-parsed its name (see _settle_serving). + re-parsed its name (see _settle_serving). The serving is type + "Weight", so the food can be a recipe ingredient; the server then adds + a "g" measure. Args: extra_nutrients: Additional nutrients beyond the core macros above @@ -751,7 +756,7 @@ def create_custom_food( "name": serving_name, "value": serving_grams, "amount": serving_quantity, - "type": "Atomic", + "type": _WEIGHED_MEASURE, } ], "labelType": "AMERICAN_2016", @@ -894,10 +899,11 @@ def update_custom_food( A measure is `amount` units of `name` weighing `value` grams in total, and the app renders the serving as " ". Setting the - weight therefore also resets the quantity (to serving_quantity, or 1) - and makes the measure a named ("Atomic") serving. Otherwise a food - first saved as "400 g" keeps amount=400 and type "Weight" and shows up - as "400 " with no weight of its own. serving_name must + weight therefore also resets the quantity (to serving_quantity, or 1). + Otherwise a food first saved as "400 g" keeps amount=400 and shows up + as "400 " with no weight of its own. + + serving_name must not lead with a quantity ("2 cookies"); pass it as serving_quantity. A quantity set without serving_grams keeps the total weight, so the per-unit weight changes. (update_recipe keeps the quantity on a @@ -972,7 +978,6 @@ def update_custom_food( measure["amount"] = ( serving_quantity if serving_quantity is not None else 1.0 ) - measure["type"] = "Atomic" updates = {} if per_serving: grams = measure["value"] @@ -1055,7 +1060,8 @@ def create_recipe( Each ingredient references another food by ID plus a gram weight. The server derives the nutrients and the "full recipe" weight from the ingredients on every save (verified live with `nutrients: []`), so - none are sent. + none are sent. Both are checked after saving (_check_derived_recipe), + and unweighable ingredients are refused first (_check_recipe_ingredient). This creates a *weight-based* recipe: measures are type "Weight", and the nutrients the server derives are stored per-100g. @@ -1096,7 +1102,8 @@ def create_recipe( elif serving_grams is None: serving_grams = batch_grams _check_serving_fits(serving_grams, batch_grams) - ingredient_rows = self._ingredient_rows(parsed) + ingredient_foods = self._ingredient_foods([fid for fid, _, _ in parsed]) + ingredient_rows = _ingredient_rows(parsed, ingredient_foods) # The serving leads so the server makes it the app's default measure. measures = [ @@ -1156,7 +1163,12 @@ def create_recipe( food = self.get_food(food_id) measure_id = self._recipe_serving(food)["id"] food, serving = self._settle_recipe_serving( - food, measure_id, serving_name, serving_quantity, serving_grams + food, + measure_id, + serving_name, + serving_quantity, + serving_grams, + ingredient_foods, ) except Exception as e: # Anything here (timeouts, 5xx, a malformed read-back) happens after @@ -1213,14 +1225,20 @@ def _recipe_serving(cls, food: dict) -> dict: return servings[0] def _settle_recipe_serving( - self, food: dict, measure_id: int, name: str, quantity: float, grams: float + self, + food: dict, + measure_id: int, + name: str, + quantity: float, + grams: float, + ingredient_foods: dict[int, dict], ) -> tuple[dict, dict]: - """_settle_serving, then check that the server derived the recipe's - nutrients (a re-send goes out with them empty, so this proves it).""" + """_settle_serving, then check the batch weight and nutrients the + server derived (see _check_derived_recipe).""" food, serving = self._settle_serving( food, measure_id, name, quantity, grams, recipe=True ) - _check_derived_nutrients(food) + _check_derived_recipe(food, ingredient_foods) return food, serving def _settle_serving( @@ -1302,7 +1320,7 @@ def update_recipe( The food id and measure ids survive (verified live). Only fields passed change. `ingredients` (create_recipe's tuple form) replaces the whole list; the server re-derives nutrients and the "full recipe" weight from - it. + it, checked as in create_recipe (kept ingredients too). The serving (the recipe's one non-fixed measure) is only touched when a serving field is passed, or when new ingredients change the batch @@ -1362,7 +1380,8 @@ def update_recipe( if comments is not None: food["comments"] = comments if parsed is not None: - food["ingredients"] = self._ingredient_rows(parsed) + ingredient_foods = self._ingredient_foods([fid for fid, _, _ in parsed]) + food["ingredients"] = _ingredient_rows(parsed, ingredient_foods) batch_grams = _ingredient_grams(food) batch_changed = abs(batch_grams - batch_before) > _GRAMS_TOL @@ -1395,6 +1414,10 @@ def update_recipe( elif serving_grams is not None: serving["value"] = serving_grams _check_serving_fits(serving["value"], batch_grams) + if parsed is None: + ingredient_foods = self._ingredient_foods( + [i["foodId"] for i in food["ingredients"]] + ) # Sent empty so the read-back proves the server derived them anew. food["nutrients"] = [] @@ -1402,7 +1425,7 @@ def update_recipe( try: if serving is None: food = self.get_food(food_id) - _check_derived_nutrients(food) + _check_derived_recipe(food, ingredient_foods) else: food, serving = self._settle_recipe_serving( self.get_food(food_id), @@ -1410,6 +1433,7 @@ def update_recipe( serving["name"], serving["amount"], serving["value"], + ingredient_foods, ) except Exception as e: # The edit is saved; say so rather than leave the caller unsure. @@ -1450,33 +1474,12 @@ def update_recipe( ) return result - def _ingredient_rows( - self, parsed: list[tuple[int, float, int | None]] - ) -> list[dict]: - """Recipe ingredient rows, resolving each food's measure and translation. - - Rows carry id 0: a saved ingredient list replaces the old one and gets - fresh row ids (verified live). - """ - foods = self.get_foods([fid for fid, _, _ in parsed]) - food_by_id = {f["id"]: f for f in foods} - - rows = [] - for food_id, grams, measure_id in parsed: - food = food_by_id[food_id] - rows.append( - { - "id": 0, - "foodId": food_id, - "measureId": _gram_measure_id(food) - if measure_id is None - else measure_id, - "translationId": _translation_id(food), - "grams": grams, - "value": grams, - } - ) - return rows + def _ingredient_foods(self, food_ids: list[int]) -> dict[int, dict]: + """The ingredients' foods by id, each passing _check_recipe_ingredient.""" + foods = {f["id"]: f for f in self.get_foods(food_ids)} + for food_id in dict.fromkeys(food_ids): + _check_recipe_ingredient(foods[food_id]) + return foods # ------------------------------------------------------------------ # Recipe import (free-text ingredients) @@ -2300,31 +2303,67 @@ def _check_serving_fits(serving_grams: float, batch_grams: float) -> None: ) -def _check_derived_nutrients(food: dict) -> None: - """The client sends no nutrients, so the server must have derived them. +# A wrong batch weight scales every nutrient alike, so these suffice. +_RECIPE_CHECKED_NUTRIENTS = ( + NUTRIENT_IDS["energy"], + NUTRIENT_IDS["protein"], + NUTRIENT_IDS["fat"], + NUTRIENT_IDS["carbs"], +) - Derived means energy is present: a water-only recipe reads back with - energy 0 (and every other nutrient 0), verified live. - """ - energy = next( - ( - n["amount"] - for n in food.get("nutrients") or [] - if n["id"] == NUTRIENT_IDS["energy"] - ), - None, - ) - if energy is None: + +def _check_derived_recipe(food: dict, ingredient_foods: dict[int, dict]) -> None: + """Energy must be present (water derives to 0), "full recipe" must be the + ingredient total, and the macros their gram-weighted per-100 g amounts + (six real recipes matched to float precision).""" + label = f"Recipe {food['id']} was saved, but" + stored = {n["id"]: n["amount"] for n in food.get("nutrients") or []} + if NUTRIENT_IDS["energy"] not in stored: + raise CronometerError( + f"{label} the server returned no derived nutrients for it" + ) + batch = _ingredient_grams(food) + full = [ + m["value"] + for m in food["measures"] + if _measure_key(m["name"]) == _FULL_RECIPE_MEASURE + ] + if len(full) != 1: raise CronometerError( - f"Recipe {food['id']} was saved, but the server returned no derived " - f"nutrients for it" + f"{label} it reads back with {len(full)} 'full recipe' measures " + f"{full}, so its batch weight is unknown" ) + if abs(full[0] - batch) > _GRAMS_TOL: + raise CronometerError( + f"{label} Cronometer stored its batch weight as {full[0]:g} g " + f"instead of the ingredients' {batch:g} g, so its per-100 g " + f"nutrients are wrong" + ) + per_100g = { + fid: {n["id"]: n["amount"] for n in f["nutrients"]} + for fid, f in ingredient_foods.items() + } + for nid in _RECIPE_CHECKED_NUTRIENTS: + expected = ( + sum( + per_100g[i["foodId"]].get(nid, 0) * i["grams"] + for i in food["ingredients"] + ) + / batch + ) + got = stored.get(nid, 0) + if not math.isclose(got, expected, rel_tol=1e-4, abs_tol=1e-3): + raise CronometerError( + f"{label} Cronometer derived nutrient {nid} as {got:g} per " + f"100 g, but its ingredients give {expected:g}" + ) def _check_batch_matches_ingredients(food: dict, batch_grams: float) -> None: """Refuse a recipe whose stored "full recipe" weight isn't its ingredient total: its batch weight was set in the app, which this client doesn't - model. An id-0 "full recipe" is the server's placeholder, not stored.""" + model, or an ingredient's grams weren't counted (_check_recipe_ingredient). + An id-0 "full recipe" is the server's placeholder, not stored.""" for m in food["measures"]: if ( _measure_key(m["name"]) == _FULL_RECIPE_MEASURE @@ -2332,9 +2371,10 @@ def _check_batch_matches_ingredients(food: dict, batch_grams: float) -> None: and abs(m["value"] - batch_grams) > _GRAMS_TOL ): raise CronometerError( - f"Recipe {food['id']}'s batch weight ({m['value']:g} g) was set in " - f"the Cronometer app and doesn't match its ingredients " - f"({batch_grams:g} g); edit it there" + f"Recipe {food['id']}'s stored batch weight ({m['value']:g} g) " + f"doesn't match its ingredients ({batch_grams:g} g): it was set " + f"in the Cronometer app, or an ingredient's grams don't count " + f"toward it (see create_recipe); edit it there" ) @@ -2365,8 +2405,9 @@ def _serving_fraction(batch_grams: float, servings_per_batch: float) -> float: def _custom_food_serving(food: dict, measure_id: int | None) -> dict: - """measure_id's measure, else the sole one. Not defaultMeasureId: the - server moves it to the last-logged measure (seen live).""" + """measure_id's measure, else the sole one besides g/oz (the server adds a + g beside a new food's serving). Not defaultMeasureId: the server moves it + to the last-logged measure (seen live).""" measures = food.get("measures") or [] label = f"Custom food {food['id']}" if not measures: @@ -2376,6 +2417,9 @@ def _custom_food_serving(food: dict, measure_id: int | None) -> dict: ) listed = [(m["id"], m["name"], m["value"]) for m in measures] if measure_id is None: + servings = [m for m in measures if not _is_weight_unit(m["name"])] + if len(servings) == 1: + return servings[0] if len(measures) > 1: raise CronometerError( f"{label} has {len(measures)} measures (id, name, grams) " @@ -2391,6 +2435,44 @@ def _custom_food_serving(food: dict, measure_id: int | None) -> dict: ) +def _ingredient_rows( + parsed: list[tuple[int, float, int | None]], foods: dict[int, dict] +) -> list[dict]: + """Recipe ingredient rows, resolving each food's measure and translation. + + Rows carry id 0: a saved ingredient list replaces the old one and gets + fresh row ids (verified live). + """ + return [ + { + "id": 0, + "foodId": food_id, + "measureId": _gram_measure_id(foods[food_id]) + if measure_id is None + else measure_id, + "translationId": _translation_id(foods[food_id]), + "grams": grams, + "value": grams, + } + for food_id, grams, measure_id in parsed + ] + + +def _check_recipe_ingredient(food: dict) -> None: + """The server counts an ingredient's grams in the batch only if all its + measures are "Weight", but always adds its nutrients (verified live: 80 g + of a 400 kcal/100 g "Atomic" food gave a 0 g batch at 320 kcal/100 g).""" + others = [m for m in food["measures"] if m["type"] != _WEIGHED_MEASURE] + if not others: + return + raise CronometerError( + f"Food {food['id']} ({food['name']!r}) can't be a recipe ingredient: " + f"its measures {[(m['name'], m['type']) for m in others]} aren't type " + f"'Weight', and Cronometer leaves such a food's grams out of a " + f"recipe's batch weight, which would inflate its per-100 g nutrients" + ) + + def _gram_measure_id(food: dict) -> int: """Return the food's gram measure ID, for use as a recipe ingredient. diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index bf3efd0..4e50172 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -1066,6 +1066,8 @@ def add_custom_food( Nutrient amounts should be for the full serving size specified. After creation, use the returned food_id with add_food_entry to log it. + Cronometer also gives the food a "g" measure, so it can be logged in + grams. The food can be a recipe ingredient (add_recipe). Args: name: Food name. @@ -1271,6 +1273,12 @@ def add_recipe( Use search_foods to find each ingredient's food_id. After creation, use the returned food_id with add_food_entry to log it. + The batch weight and nutrients Cronometer derives are checked after + saving, and a mismatch raises. Ingredients Cronometer can't weigh (a food + with a non-"Weight" measure, e.g. a supplement "tablet") are refused + before saving, including custom foods made before Oct 13, 2026 + ("Atomic" servings). + Args: name: Recipe name. ingredients: List of {"food_id": int, "grams": float} objects, one per diff --git a/tests/test_client.py b/tests/test_client.py index 6836e17..8f395c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -390,6 +390,12 @@ def _save(self, food: dict) -> dict: food["id"] = self.next_id self.next_id += 1 food["source"] = "Custom" + new_food = food["id"] == self.next_id - 1 and food["id"] not in self.foods + if new_food and all(m["name"] != "g" for m in food["measures"]): + # Live: a new food without a "g" measure gets one. + food["measures"].append( + {"id": 0, "name": "g", "value": 1, "amount": 1, "type": "Weight"} + ) for m in food["measures"]: if not m["id"]: m["id"] = self.next_measure_id @@ -398,7 +404,15 @@ def _save(self, food: dict) -> dict: if not food.get("defaultMeasureId") and not _only_gram(food): food["defaultMeasureId"] = food["measures"][0]["id"] if food.get("ingredients"): - batch = sum(i["grams"] for i in food["ingredients"]) + # Live: unweighable ingredients add nutrients but not grams. + batch = sum( + i["grams"] + for i in food["ingredients"] + if all( + m["type"] == "Weight" + for m in self.ingredient_foods[i["foodId"]]["measures"] + ) + ) for m in food["measures"]: if m["name"] == "full recipe": m["value"] = batch @@ -409,8 +423,9 @@ def _save(self, food: dict) -> dict: totals[n["id"]] = ( totals.get(n["id"], 0) + n["amount"] * i["grams"] / 100 ) + # Live: a 0 g batch stores the totals as the per-100 g amounts. food["nutrients"] = [ - {"id": nid, "amount": amount * 100 / batch} + {"id": nid, "amount": amount * 100 / (batch or 100)} for nid, amount in sorted(totals.items()) ] self.foods[food["id"]] = food @@ -1492,6 +1507,7 @@ def test_create_custom_food_serving_measure(tmp_path, kwargs, name, amount): assert state["endpoints"] == ["add_food", "get_food"] (measure,) = state["payloads"][0]["data"]["measures"] assert (measure["name"], measure["amount"], measure["value"]) == (name, amount, 30) + assert measure["type"] == "Weight" assert result == {"food_id": 4242, "measure_id": 9000} @@ -1509,12 +1525,13 @@ def test_create_custom_food_resends_reparsed_serving(tmp_path, serving_name): assert state["endpoints"] == ["add_food", "get_food", "add_food", "get_food"] sent = state["payloads"][2]["data"] assert sent["nutrients"] == state["payloads"][0]["data"]["nutrients"] - (measure,) = server.foods[result["food_id"]]["measures"] + measure, gram = server.foods[result["food_id"]]["measures"] assert (measure["name"], measure["amount"], measure["value"]) == ( serving_name, 1, 250, ) + assert (gram["name"], gram["value"], gram["type"]) == ("g", 1, "Weight") def test_create_custom_food_raises_with_id_when_serving_wont_settle(tmp_path): @@ -1595,7 +1612,7 @@ def test_create_custom_food_failure_after_save_names_the_id( (lambda food: food.update(measures=[]), "has no serving measure"), ( lambda food: food["measures"].append({**food["measures"][0], "id": 1}), - r"has 2 measures \(id, name, grams\) \[\(9000, 'serving', 100", + r"has 3 measures \(id, name, grams\) \[\(9000, 'serving', 100", ), ], ) @@ -1675,11 +1692,12 @@ def test_update_custom_food_gram_food_takes_per_gram_nutrients(tmp_path): @pytest.mark.parametrize("kwargs", [{"calories": 3}, {"serving_grams": 2}]) def test_update_custom_food_two_measures_need_measure_id(tmp_path, kwargs): client, state, fake, _ = _create_gram_food(tmp_path) - fake.foods[4242]["measures"].append(dict(_CUP)) + fake.foods[4242]["measures"] += [dict(_CUP), {**_CUP, "id": 9002, "name": "bowl"}] with pytest.raises( CronometerError, - match=r"has 2 measures .*\(9000, 'g', 1\), \(9001, 'cup', 240\)\]; pass measure_id", + match=r"has 3 measures .*\(9000, 'g', 1\), \(9001, 'cup', 240\), " + r"\(9002, 'bowl', 240\)\]; pass measure_id", ): client.update_custom_food(4242, **kwargs) assert state["endpoints"] == ["add_food", "get_food", "get_food"] @@ -1731,7 +1749,7 @@ def test_add_custom_food_tool_sends_serving_quantity(tmp_path, monkeypatch): def test_update_custom_food_tool_passes_measure_id(tmp_path, monkeypatch): client, _, fake, _ = _create_gram_food(tmp_path) - fake.foods[4242]["measures"].append(dict(_CUP)) + fake.foods[4242]["measures"] += [dict(_CUP), {**_CUP, "id": 9002, "name": "bowl"}] monkeypatch.setattr(mcp_server, "_get_client", lambda: client) refused = json.loads(mcp_server.update_custom_food(4242, calories=120)) @@ -1741,3 +1759,18 @@ def test_update_custom_food_tool_passes_measure_id(tmp_path, monkeypatch): out = json.loads(mcp_server.update_custom_food(4242, calories=120, measure_id=9001)) assert out["status"] == "success" assert {n["id"]: n["amount"] for n in fake.foods[4242]["nutrients"]}[208] == 50.0 + + +def test_created_custom_food_takes_per_serving_edits_without_measure_id(tmp_path): + fake = FakeFoodServer() + client, _ = make_server_client(tmp_path, fake) + created = _create(client, serving_name="bar", serving_grams=40) + + client.update_custom_food(4242, calories=120) + + food = fake.foods[4242] + assert [(m["id"], m["name"]) for m in food["measures"]] == [ + (created["measure_id"], "bar"), + (9001, "g"), + ] + assert {n["id"]: n["amount"] for n in food["nutrients"]}[208] == 300.0 diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 7d352dc..b358396 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -188,7 +188,7 @@ def test_update_custom_food_serving_grams_resets_quantity_and_type(tmp_path): assert measure["name"] == "serving" assert measure["value"] == 250 assert measure["amount"] == 1.0 - assert measure["type"] == "Atomic" + assert measure["type"] == "Weight" def test_update_custom_food_serving_quantity(tmp_path): @@ -200,7 +200,7 @@ def test_update_custom_food_serving_quantity(tmp_path): client.update_custom_food(FOOD_ID, serving_quantity=1) (measure,) = state["payloads"][1]["data"]["measures"] - assert (measure["amount"], measure["value"], measure["type"]) == (1, 250, "Atomic") + assert (measure["amount"], measure["value"], measure["type"]) == (1, 250, "Weight") client.update_custom_food(FOOD_ID, serving_grams=30, serving_quantity=2) (measure,) = state["payloads"][3]["data"]["measures"] @@ -597,12 +597,14 @@ def two_measure_food(default: int) -> dict: def test_update_custom_food_with_several_measures_requires_measure_id( tmp_path, default, kwargs ): - client, state = make_cold_client(tmp_path, [two_measure_food(default)]) + food = two_measure_food(default) + food["measures"].append(_measure(902, "bowl", 300)) + client, state = make_cold_client(tmp_path, [food]) with pytest.raises( CronometerError, - match=r"has 2 measures \(id, name, grams\) \[\(900, 'serving', 50\), " - r"\(901, 'g', 1\)\]; pass measure_id", + match=r"has 3 measures \(id, name, grams\) \[\(900, 'serving', 50\), " + r"\(901, 'g', 1\), \(902, 'bowl', 300\)\]; pass measure_id", ): client.update_custom_food(FOOD_ID, **kwargs) assert len(state["payloads"]) == 1 # the read, no save @@ -683,3 +685,62 @@ def test_update_custom_food_refuses_renaming_serving_to_weight_unit(tmp_path, un with pytest.raises(ValueError, match="is a weight unit"): client.update_custom_food(FOOD_ID, serving_name=unit) assert state["payloads"] == [] + + +def _measure(mid: int, name: str, value, mtype: str = "Weight") -> dict: + return {"id": mid, "name": name, "amount": 1, "value": value, "type": mtype} + + +@pytest.mark.parametrize( + ("measures", "picked"), + [ + ([_measure(900, "bar", 40), _measure(901, "g", 1)], 900), + ([_measure(901, "g", 1), _measure(900, "bar", 40)], 900), + ( + [ + _measure(901, "g", 1), + _measure(902, "oz", 28.35), + _measure(900, "cup", 240), + ], + 900, + ), + ([_measure(901, " G ", 1)], 901), + ], +) +def test_serving_is_the_one_measure_besides_g_and_oz(tmp_path, measures, picked): + food = custom_food() + food["measures"] = measures + client, state = make_cold_client(tmp_path, [food, OK]) + + client.update_custom_food(FOOD_ID, calories=100) + + by_id = {m["id"]: m for m in measures} + grams = by_id[picked]["value"] + assert nutrients_by_id(state["payloads"][1])[208] == round(100 * 100 / grams, 2) + + +@pytest.mark.parametrize( + "measures", + [ + [_measure(901, "g", 1), _measure(902, "oz", 28.35)], + [_measure(900, "bar", 40), _measure(903, "cup", 240), _measure(901, "g", 1)], + ], +) +def test_serving_still_ambiguous_without_exactly_one_other(tmp_path, measures): + food = custom_food() + food["measures"] = measures + client, state = make_cold_client(tmp_path, [food]) + + with pytest.raises(CronometerError, match="pass measure_id"): + client.update_custom_food(FOOD_ID, calories=100) + assert len(state["payloads"]) == 1 + + +def test_update_leaves_measure_types_alone(tmp_path): + food = custom_food() + client, state = make_cold_client(tmp_path, [food, OK]) + + client.update_custom_food(FOOD_ID, name="Renamed") + + (measure,) = state["payloads"][1]["data"]["measures"] + assert measure["type"] == "Atomic" diff --git a/tests/test_food_log.py b/tests/test_food_log.py index 0dd1523..4f2f88c 100644 --- a/tests/test_food_log.py +++ b/tests/test_food_log.py @@ -40,7 +40,7 @@ "name": "Egg", "defaultMeasureId": 10, "measures": [ - {"id": 10, "name": "large", "value": 50, "amount": 1, "type": "Atomic"}, + {"id": 10, "name": "large", "value": 50, "amount": 1, "type": "Weight"}, {"id": 11, "name": "g", "value": 1, "amount": 1, "type": "Weight"}, ], "nutrients": [ diff --git a/tests/test_recipe_edit.py b/tests/test_recipe_edit.py index eb89350..d5d7b45 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -24,12 +24,13 @@ RECIPE_ID = 85008439 OK = {"result": "SUCCESS", "messages": [], "id": RECIPE_ID} +# Live, these three have only "Weight" measures. BANANA = { "id": 450856, "name": "Banana, Fresh", "defaultMeasureId": 998940, "measures": [ - {"id": 998940, "name": "medium", "value": 118, "amount": 1, "type": "Atomic"}, + {"id": 998940, "name": "medium", "value": 118, "amount": 1, "type": "Weight"}, {"id": 998949, "name": "g", "value": 1, "amount": 1, "type": "Weight"}, ], "translations": [{"translationId": 398492, "name": "Banana, Fresh"}], @@ -40,7 +41,7 @@ "name": "Egg, whole", "defaultMeasureId": 3770, "measures": [ - {"id": 3770, "name": "large", "value": 50, "amount": 1, "type": "Atomic"}, + {"id": 3770, "name": "large", "value": 50, "amount": 1, "type": "Weight"}, {"id": 3776, "name": "g", "value": 1, "amount": 1, "type": "Weight"}, ], "translations": [{"translationId": 1893, "name": "Egg, whole"}], @@ -59,7 +60,7 @@ "name": "Peanut Butter", "defaultMeasureId": 1960, "measures": [ - {"id": 1960, "name": "tbsp", "value": 16, "amount": 1, "type": "Atomic"} + {"id": 1960, "name": "tbsp", "value": 16, "amount": 1, "type": "Weight"} ], "nutrients": [{"id": 208, "amount": 588.0}, {"id": 204, "amount": 50.0}], } @@ -181,7 +182,7 @@ def endpoints(state) -> list[str]: return state["endpoints"] -SAVE = ["get_food", "add_food", "get_food"] # fetch, save, read back +SAVE = ["get_food", "get_foods", "add_food", "get_food"] def add_food_payloads(state) -> list[dict]: @@ -478,11 +479,7 @@ def test_non_serving_edits_ignore_the_serving(tmp_path, kind, kwargs): result = client.update_recipe(RECIPE_ID, **kwargs) - assert endpoints(state) == [ - e - for e in ["get_food", "get_foods", "add_food", "get_food"] - if e != "get_foods" or "ingredients" in kwargs - ] + assert endpoints(state) == SAVE # kept ingredients are checked too assert saved(state)["measures"] == food["measures"] assert "serving" not in result and "servings_per_batch" not in result @@ -817,7 +814,7 @@ def test_save_failure_body_raises_after_one_retry(tmp_path): with pytest.raises(CronometerError, match="nope"): client.update_recipe(RECIPE_ID, name="X") - assert endpoints(state) == ["get_food", "add_food", "add_food"] # retried once + assert endpoints(state) == ["get_food", "get_foods", "add_food", "add_food"] def test_update_custom_food_still_refuses_recipes(tmp_path): @@ -1753,8 +1750,9 @@ def test_batch_weight_set_in_app_is_refused(tmp_path, full_id): if full_id: with pytest.raises( CronometerError, - match=r"batch weight \(500 g\) was set in the Cronometer app and " - r"doesn't match its ingredients \(152 g\); edit it there", + match=r"stored batch weight \(500 g\) doesn't match its ingredients " + r"\(152 g\): it was set in the Cronometer app, or an ingredient's " + r"grams don't count toward it", ): client.update_recipe(RECIPE_ID, name="X") assert endpoints(state) == ["get_food"] @@ -1797,3 +1795,250 @@ def handle(self, endpoint, payload): assert err.value.__cause__ is srv.timeout assert state["endpoints"].count("add_food") == 1 assert srv.foods[RECIPE_ID]["name"] == "X" + + +LEGACY_ID = 85102741 + + +def legacy_custom_food(*measures: dict) -> dict: + """400 kcal/100 g, with the "Atomic" serving older versions saved.""" + return { + "id": LEGACY_ID, + "name": "ZZ custom", + "source": "Custom", + "owner": 42, + "measures": list(measures) + or [{"id": 7, "name": "serving", "value": 100, "amount": 1, "type": "Atomic"}], + "translations": [{"translationId": 94518891, "name": "ZZ custom"}], + "nutrients": [ + {"id": 208, "amount": 400.0}, + {"id": 203, "amount": 20.0}, + {"id": 204, "amount": 10.0}, + {"id": 205, "amount": 50.0}, + ], + } + + +def _m(mid, name, value, mtype): + return {"id": mid, "name": name, "value": value, "amount": 1, "type": mtype} + + +UNWEIGHABLE = { + "atomic serving": legacy_custom_food(), + "atomic serving, weight g": legacy_custom_food( + _m(7, "serving", 100, "Atomic"), _m(8, "g", 1, "Weight") + ), + "weight serving, atomic g": legacy_custom_food( + _m(7, "serving", 100, "Weight"), _m(8, "g", 1, "Atomic") + ), +} + + +@pytest.mark.parametrize("food", UNWEIGHABLE.values(), ids=UNWEIGHABLE.keys()) +def test_create_recipe_refuses_unweighable_ingredient_before_saving(tmp_path, food): + srv = FakeFoodServer(ingredient_foods=[EGG, food]) + client, state = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError) as err: + client.create_recipe("R", ingredients=[(1234, 50), (LEGACY_ID, 80)]) + + msg = str(err.value) + assert msg.startswith( + f"Food {LEGACY_ID} ('ZZ custom') can't be a recipe ingredient: its measures" + ) + assert "leaves such a food's grams out of a recipe's batch weight" in msg + assert state["endpoints"] == ["get_foods"] # nothing created + assert srv.foods == {} + + +@pytest.mark.parametrize( + "food", + [ + {**legacy_custom_food(_m(7, "tablet", 100, "Atomic")), "source": "CRDB"}, + legacy_custom_food(_m(7, "serving", 0, "Atomic")), + legacy_custom_food(_m(7, "serving", 100, "Atomic"), _m(8, "x", None, "Atomic")), + legacy_custom_food(_m(7, "serving", 1, "Recipe"), _m(8, "g", 200, "Recipe")), + ], + ids=["database tablet", "weightless", "partly weightless", "serving-based"], +) +def test_other_unweighable_ingredients_are_refused(tmp_path, food): + srv = FakeFoodServer(ingredient_foods=[food]) + client, _ = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError) as err: + client.create_recipe("R", ingredients=[(LEGACY_ID, 80)]) + + assert "can't be a recipe ingredient" in str(err.value) + + +def test_update_recipe_refuses_unweighable_new_ingredient(tmp_path): + srv = FakeFoodServer( + foods=[recipe()], ingredient_foods=[BANANA, PB, legacy_custom_food()] + ) + client, state = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError, match=r"can't be a recipe ingredient"): + client.update_recipe(RECIPE_ID, ingredients=[(LEGACY_ID, 152)]) + + assert endpoints(state) == ["get_food", "get_foods"] + + +@pytest.mark.parametrize( + "kwargs", [{"name": "X"}, {"comments": "c"}, {"serving_grams": 50}] +) +def test_update_recipe_refuses_unweighable_kept_ingredient(tmp_path, kwargs): + food = recipe() + food["ingredients"][1]["foodId"] = LEGACY_ID + srv = FakeFoodServer(foods=[food], ingredient_foods=[BANANA, legacy_custom_food()]) + client, state = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError, match=r"can't be a recipe ingredient"): + client.update_recipe(RECIPE_ID, **kwargs) + + assert "add_food" not in endpoints(state) + + +def test_repeated_ingredient_food_is_fetched_and_counted(tmp_path): + srv = FakeFoodServer(ingredient_foods=[EGG, WATER]) + client, state = make_server_client(tmp_path, srv) + + result = client.create_recipe( + "R", ingredients=[(1234, 50), (455493, 100), (1234, 50)] + ) + + assert result["total_grams"] == 200 + assert state["payloads"][0]["ids"] == [1234, 455493, 1234] + + +class ShortBatchServer(FakeFoodServer): + """Treats `uncounted` as unweighable, as the live server does.""" + + def __init__(self, *args, uncounted: int, **kwargs): + super().__init__(*args, **kwargs) + self.uncounted = uncounted + + def _save(self, food): + real = self.ingredient_foods[self.uncounted] + self.ingredient_foods[self.uncounted] = { + **real, + "measures": [{**m, "type": "Atomic"} for m in real["measures"]], + } + try: + return super()._save(food) + finally: + self.ingredient_foods[self.uncounted] = real + + +@pytest.mark.parametrize( + ("ingredients", "stored"), + [([(1234, 80)], 0), ([(1234, 80), (455493, 50)], 50)], + ids=["lone", "beside a counted one"], +) +def test_create_recipe_raises_on_a_short_batch(tmp_path, ingredients, stored): + srv = ShortBatchServer(ingredient_foods=[EGG, WATER], uncounted=1234) + client, state = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError) as err: + client.create_recipe("R", ingredients=ingredients) + + batch = sum(g for _, g in ingredients) + assert str(err.value) == ( + f"The recipe exists (id 4242); don't re-create it. Recipe 4242 was " + f"saved, but Cronometer stored its batch weight as {stored} g instead " + f"of the ingredients' {batch} g, so its per-100 g nutrients are wrong" + ) + assert state["endpoints"].count("add_food") == 1 + + +def test_update_recipe_raises_on_a_short_batch(tmp_path): + srv = ShortBatchServer( + foods=[recipe()], ingredient_foods=[BANANA, PB, EGG], uncounted=1234 + ) + client, _ = make_server_client(tmp_path, srv) + + with pytest.raises( + CronometerError, + match=rf"^Recipe {RECIPE_ID} was saved; verify with get_food_details\. " + rf"Recipe {RECIPE_ID} was saved, but Cronometer stored its batch weight " + rf"as 120 g instead of the ingredients' 152 g", + ): + client.update_recipe(RECIPE_ID, ingredients=[(1234, 32), (450856, 120)]) + + +class ScaledNutrientServer(FakeFoodServer): + def __init__(self, *args, nutrient: int, factor: float, **kwargs): + super().__init__(*args, **kwargs) + self.nutrient, self.factor = nutrient, factor + + def _save(self, food): + resp = super()._save(food) + for n in self.foods[resp["id"]]["nutrients"]: + if n["id"] == self.nutrient: + n["amount"] *= self.factor + return resp + + +@pytest.mark.parametrize("nutrient", [208, 203, 204, 205]) +def test_create_recipe_raises_on_wrong_derived_nutrients(tmp_path, nutrient): + weighed = legacy_custom_food(_m(7, "serving", 100, "Weight")) + srv = ScaledNutrientServer( + ingredient_foods=[weighed], nutrient=nutrient, factor=1.25 + ) + client, _ = make_server_client(tmp_path, srv) + + with pytest.raises( + CronometerError, + match=rf"Recipe 4242 was saved, but Cronometer derived nutrient {nutrient} " + rf"as .* per 100 g, but its ingredients give", + ): + client.create_recipe("R", ingredients=[(LEGACY_ID, 100)]) + + +@pytest.mark.parametrize("nutrient", [208, 203]) +def test_update_recipe_raises_on_wrong_derived_nutrients(tmp_path, nutrient): + srv = ScaledNutrientServer( + foods=[recipe()], + ingredient_foods=[BANANA, PB], + nutrient=nutrient, + factor=0.5, + ) + client, _ = make_server_client(tmp_path, srv) + + with pytest.raises(CronometerError, match=rf"derived nutrient {nutrient} as"): + client.update_recipe(RECIPE_ID, name="X") + + +@pytest.mark.parametrize("factor", [1 + 1e-9, 1 - 1e-9, 1 + 9e-5]) +def test_float_noise_in_derived_nutrients_passes(tmp_path, factor): + srv = ScaledNutrientServer(ingredient_foods=[EGG], nutrient=208, factor=factor) + client, _ = make_server_client(tmp_path, srv) + + client.create_recipe("R", ingredients=[(1234, 100)]) + + +def test_nutrients_the_ingredients_lack_count_as_zero(tmp_path): + srv = FakeFoodServer(ingredient_foods=[EGG, WATER]) + client, _ = make_server_client(tmp_path, srv) + + result = client.create_recipe("R", ingredients=[(1234, 100), (455493, 100)]) + + stored = {n["id"]: n["amount"] for n in srv.foods[result["food_id"]]["nutrients"]} + assert stored[203] == pytest.approx(6.3) + + +def test_several_full_recipe_measures_raise(tmp_path): + class TwoFullServer(FakeFoodServer): + def _save(self, food): + resp = super()._save(food) + stored = self.foods[resp["id"]] + full = next(m for m in stored["measures"] if m["name"] == "full recipe") + stored["measures"].append({**full, "id": 77}) + return resp + + client, _ = make_server_client(tmp_path, TwoFullServer(ingredient_foods=[EGG])) + + with pytest.raises( + CronometerError, + match=r"reads back with 2 'full recipe' measures \[100.0, 100.0\]", + ): + client.create_recipe("R", ingredients=[(1234, 100)])