From 3e58aa4737f6d9454a48de24afbcdfd135d16d41 Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 13 Oct 2026 13:19:17 -0700 Subject: [PATCH 1/3] Count custom-food ingredients in a recipe's batch; verify every recipe save Cronometer adds an ingredient's grams to a recipe's "full recipe" weight only when all of the ingredient food's measures are type "Weight". create_custom_food saved its serving as "Atomic", so a recipe using one of those foods got a 0 g (or short) batch and inflated per-100 g nutrients, while create_recipe still reported success. - create_custom_food saves the serving as "Weight", as the web client does for a serving with a gram weight. The server then adds its own "g" measure, so the serving is found as the measure that isn't that g. - update_custom_food re-types weighed "Atomic" measures as "Weight" on every save, so older foods are fixed by any edit (diary math is unchanged). - create_recipe / update_recipe refuse ingredients with a non-"Weight" measure before saving, and after saving check the stored batch weight and the energy/protein/fat/carbs against the ingredients, raising on a mismatch. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- src/cronometer_api_mcp/client.py | 269 +++++++++++++++++++------- src/cronometer_api_mcp/server.py | 9 + tests/test_client.py | 43 ++++- tests/test_custom_food_edit.py | 71 ++++++- tests/test_recipe_edit.py | 319 +++++++++++++++++++++++++++++-- 6 files changed, 631 insertions(+), 86 deletions(-) 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..51cf632 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -86,6 +86,13 @@ _FULL_RECIPE_MEASURE = "full recipe" _WEIGHT_UNIT_MEASURES = frozenset({"g", "oz"}) _RECIPE_FIXED_MEASURES = _WEIGHT_UNIT_MEASURES | {_FULL_RECIPE_MEASURE} +# Measure types. Cronometer's web client saves every serving that has a gram +# weight as "Weight" (DB foods and recipes have only those). "Atomic" marks a +# count unit (a supplement "tablet"); a food with any measure not of type +# "Weight" adds its nutrients to a recipe but not its grams, so the batch +# weight comes out short (all verified live; see _check_recipe_ingredient). +_WEIGHED_MEASURE = "Weight" +_ATOMIC_MEASURE = "Atomic" # 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 +685,13 @@ 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). It is saved as a "Weight" + measure, as Cronometer's web client saves a serving with a gram + weight, so the food works as a recipe ingredient (see + _check_recipe_ingredient). The server then adds a "g" measure of its + own (unless the serving is "g"), so the food can be logged in grams; + update_custom_food's per-serving edits then need the returned + measure_id. Args: extra_nutrients: Additional nutrients beyond the core macros above @@ -751,7 +764,7 @@ def create_custom_food( "name": serving_name, "value": serving_grams, "amount": serving_quantity, - "type": "Atomic", + "type": _WEIGHED_MEASURE, } ], "labelType": "AMERICAN_2016", @@ -772,7 +785,7 @@ def create_custom_food( food = self.get_food(food_id) _, serving = self._settle_serving( food, - _custom_food_serving(food, None)["id"], + _created_serving(food, serving_name)["id"], serving_name, serving_quantity, serving_grams, @@ -894,10 +907,16 @@ 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. + + Every save also re-types the food's weighed "Atomic" measures (older + versions of this client created servings that way) as "Weight", which + is what Cronometer's web client saves for a serving with a gram + weight. Diary math is unchanged (both count grams / 100), but only + then do the food's grams count toward a recipe's batch weight. Any + edit does it, e.g. passing the food's current name. 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 +991,9 @@ def update_custom_food( measure["amount"] = ( serving_quantity if serving_quantity is not None else 1.0 ) - measure["type"] = "Atomic" + for m in food["measures"]: + if m["type"] == _ATOMIC_MEASURE and _is_positive_number(m["value"]): + m["type"] = _WEIGHED_MEASURE updates = {} if per_serving: grams = measure["value"] @@ -1055,7 +1076,11 @@ 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 read back and checked against the + ingredients, so a wrong batch raises instead of reporting success. + Ingredients whose food has a measure not of type "Weight" are refused + before saving, since the server leaves their grams out of the batch + (see _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 +1121,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 +1182,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 +1244,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 +1339,9 @@ 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, and both are checked after the save as in create_recipe. The + ingredients (new or kept) are refused before saving if their batch + weight can't be derived (see _check_recipe_ingredient). 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 +1401,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 +1435,11 @@ def update_recipe( elif serving_grams is not None: serving["value"] = serving_grams _check_serving_fits(serving["value"], batch_grams) + if parsed is None: + # Kept ingredients are checked too: the save re-derives from them. + 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 +1447,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 +1455,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 +1496,13 @@ 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 checked by _check_recipe_ingredient + before anything is saved.""" + 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 +2326,74 @@ 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. +# The nutrients _check_derived_recipe recomputes from the ingredients. 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"], +) + + +def _check_derived_recipe(food: dict, ingredient_foods: dict[int, dict]) -> None: + """Check what the server derived for a just-saved recipe. - Derived means energy is present: a water-only recipe reads back with - energy 0 (and every other nutrient 0), verified live. + The client sends no nutrients, so energy must be present (a water-only + recipe reads back with energy 0, verified live). The "full recipe" weight + must be the ingredients' total, and energy and the macros must be the + ingredients' per-100 g amounts weighted by grams: six real recipes + matched that to float precision (checked live), so anything else is a + wrong batch rather than rounding. """ - energy = next( - ( - n["amount"] - for n in food.get("nutrients") or [] - if n["id"] == NUTRIENT_IDS["energy"] - ), - None, - ) - if energy is None: + 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"{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"Recipe {food['id']} was saved, but the server returned no derived " - f"nutrients for it" + 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 +2401,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" ) @@ -2364,6 +2434,21 @@ def _serving_fraction(batch_grams: float, servings_per_batch: float) -> float: return round(batch_grams / servings_per_batch, _GRAMS_DECIMALS) +def _created_serving(food: dict, serving_name: str) -> dict: + """A just-created custom food's serving. Beside a "Weight" serving the + server adds a "g" measure of its own (seen live, unless the serving is + "g"), so the serving is the one measure that isn't that g.""" + gram = _measure_key(serving_name) == "g" + found = [m for m in food["measures"] if gram or _measure_key(m["name"]) != "g"] + if len(found) != 1: + raise CronometerError( + f"Custom food {food['id']} read back with measures " + f"{[(m['id'], m['name'], m['value']) for m in food['measures']]}; " + f"expected its serving {serving_name!r} and at most Cronometer's g" + ) + return found[0] + + 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).""" @@ -2391,6 +2476,62 @@ 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: + """Refuse an ingredient whose grams Cronometer won't count in the batch. + + The server adds every ingredient's nutrients to a recipe, but its grams + only when all of the food's measures are type "Weight". With any other + (an "Atomic" serving, even beside a "Weight" g; a serving-based recipe's + "Recipe" measures are refused untested) the stored "full recipe" weight + comes out short, 0 for a lone such ingredient, so every per-100 g + nutrient is inflated (verified live: 80 g of a 400 kcal/100 g custom food + with an "Atomic" serving stored 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 + if food.get("source") == "Custom" and all( + m["type"] == _ATOMIC_MEASURE and _is_positive_number(m["value"]) for m in others + ): + fix = ( + "It's a custom food whose servings an older version of this tool " + "saved as 'Atomic'; any update_custom_food edit (e.g. passing its " + "current name) re-saves them as 'Weight', which fixes this" + ) + else: + fix = "Use a food whose servings all have a gram weight" + 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. " + f"{fix}" + ) + + 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..f135733 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -1066,6 +1066,9 @@ 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; pass the returned measure_id to update_custom_food's per-serving + edits. The food can be a recipe ingredient (add_recipe). Args: name: Food name. @@ -1271,6 +1274,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. A custom food saved by an older version of this tool is + one of those until any update_custom_food edit re-saves it. + 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..0642fea 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -336,8 +336,10 @@ class FakeFoodServer: the first measure becomes the default (unless it's a lone "g", which keeps defaultMeasureId 0, as seen live), new measure names are re-parsed (server_reparse), and a recipe's "full recipe" weight and per-100g - nutrients are derived from its ingredients (derive_nutrients=False keeps - whatever nutrients were sent, simulating a save that derived nothing). + nutrients are derived from its ingredients, leaving out the grams of an + ingredient with a non-"Weight" measure as the live server does + (derive_nutrients=False keeps whatever nutrients were sent, simulating a + save that derived nothing). `save_responses` replaces the next add_food answers. """ @@ -390,6 +392,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 +406,16 @@ 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: an ingredient adds its grams only if all its food's + # measures are type "Weight", but always adds its nutrients. + 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 +426,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 +1510,8 @@ 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) + # As the web client saves a weighed serving, so it counts in recipes. + assert measure["type"] == "Weight" assert result == {"food_id": 4242, "measure_id": 9000} @@ -1509,12 +1529,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): @@ -1592,10 +1613,18 @@ def test_create_custom_food_failure_after_save_names_the_id( @pytest.mark.parametrize( ("break_read_back", "match"), [ - (lambda food: food.update(measures=[]), "has no serving measure"), + (lambda food: food.update(measures=[]), r"read back with measures \[\];"), ( lambda food: food["measures"].append({**food["measures"][0], "id": 1}), - r"has 2 measures \(id, name, grams\) \[\(9000, 'serving', 100", + ( + r"read back with measures \[\(9000, 'serving', 100.0\), " + r"\(9001, 'g', 1\), \(1, 'serving', 100.0\)\]; expected its " + r"serving 'serving' and at most Cronometer's g" + ), + ), + ( + lambda food: food["measures"].pop(0), + r"read back with measures \[\(9001, 'g', 1\)\]", ), ], ) diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 7d352dc..4e26c93 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"] @@ -683,3 +683,70 @@ 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"] == [] + + +# --------------------------------------------------------------------------- +# Weighed "Atomic" measures are re-typed "Weight" on every save +# --------------------------------------------------------------------------- + + +def _measure(mid: int, name: str, value, mtype: str, amount: float = 1) -> dict: + return {"id": mid, "name": name, "amount": amount, "value": value, "type": mtype} + + +@pytest.mark.parametrize( + "kwargs", + [ + {"name": "Test Food"}, # the same name: the cheapest re-save + {"name": "Renamed"}, + {"calories": 120}, + {"serving_quantity": 2}, + {"serving_grams": 40}, + ], +) +def test_any_edit_retypes_weighed_atomic_measures(tmp_path, kwargs): + """Older versions saved servings as "Atomic", whose grams Cronometer + leaves out of a recipe's batch weight; every save re-types them.""" + food = custom_food() + client, state = make_cold_client(tmp_path, [food, OK]) + + client.update_custom_food(FOOD_ID, **kwargs) + + (measure,) = state["payloads"][1]["data"]["measures"] + assert measure["type"] == "Weight" + assert measure["id"] == 900 # same measure, so diary entries keep it + + +def test_retyping_covers_every_measure_and_keeps_the_rest(tmp_path): + food = custom_food() + food["measures"] = [ + _measure(900, "serving", 50, "Atomic"), + _measure(901, "cookies", 30, "Atomic", amount=2), + _measure(902, "g", 1, "Atomic"), + _measure(903, "cup", 240, "Weight"), + ] + client, state = make_cold_client(tmp_path, [food, OK]) + + client.update_custom_food(FOOD_ID, name="Renamed") + + sent = state["payloads"][1]["data"]["measures"] + assert [(m["id"], m["amount"], m["value"], m["type"]) for m in sent] == [ + (900, 1, 50, "Weight"), + (901, 2, 30, "Weight"), + (902, 1, 1, "Weight"), + (903, 1, 240, "Weight"), + ] + + +@pytest.mark.parametrize("value", [0, None, -5, float("nan")]) +def test_weightless_atomic_measures_stay_atomic(tmp_path, value): + """A count unit with no real gram weight (the web form's "n/a") can't be + a weight, so it isn't re-typed.""" + food = custom_food() + food["measures"].append(_measure(901, "tablet", value, "Atomic")) + client, state = make_cold_client(tmp_path, [food, OK]) + + client.update_custom_food(FOOD_ID, name="Renamed") + + sent = state["payloads"][1]["data"]["measures"] + assert [(m["id"], m["type"]) for m in sent] == [(900, "Weight"), (901, "Atomic")] diff --git a/tests/test_recipe_edit.py b/tests/test_recipe_edit.py index eb89350..ce331e8 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -8,6 +8,7 @@ import copy import json import math +import re import httpx import pytest @@ -24,12 +25,13 @@ RECIPE_ID = 85008439 OK = {"result": "SUCCESS", "messages": [], "id": RECIPE_ID} +# Database foods' measures are all type "Weight" (seen live on these three). 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 +42,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 +61,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 +183,8 @@ def endpoints(state) -> list[str]: return state["endpoints"] -SAVE = ["get_food", "add_food", "get_food"] # fetch, save, read back +# fetch, check the ingredients, save, read back +SAVE = ["get_food", "get_foods", "add_food", "get_food"] def add_food_payloads(state) -> list[dict]: @@ -478,11 +481,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 +816,8 @@ 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 + # 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 +1753,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 +1798,297 @@ 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" + + +# --------------------------------------------------------------------------- +# Ingredients Cronometer can't weigh, and checking the derived batch +# --------------------------------------------------------------------------- + +LEGACY_ID = 85102741 + + +def legacy_custom_food(*measures: dict) -> dict: + """A label-only custom food (400 kcal/100 g) as older versions of this + client saved it: an "Atomic" serving with a gram weight.""" + 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 = { + # The live repro: a lone weighed Atomic serving. + "atomic serving": legacy_custom_food(), + # Live: a "Weight" g beside an Atomic serving still isn't counted, nor is + # an Atomic g beside a "Weight" serving. + "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") + ), +} +RETYPE_FIX = "any update_custom_food edit (e.g. passing its current name)" + + +@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 RETYPE_FIX in msg + assert state["endpoints"] == ["get_foods"] # nothing created + assert srv.foods == {} + + +@pytest.mark.parametrize( + "food", + [ + # A database supplement whose "tablet" is a count, not a weight. + {**legacy_custom_food(_m(7, "tablet", 100, "Atomic")), "source": "CRDB"}, + # A custom count unit with no gram weight (the web form's "n/a"). + legacy_custom_food(_m(7, "serving", 0, "Atomic")), + legacy_custom_food(_m(7, "serving", 100, "Atomic"), _m(8, "x", None, "Atomic")), + # A serving-based recipe's measures. + legacy_custom_food(_m(7, "serving", 1, "Recipe"), _m(8, "g", 200, "Recipe")), + ], + ids=["database tablet", "weightless", "partly weightless", "serving-based"], +) +def test_unfixable_ingredients_suggest_another_food(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)]) + + msg = str(err.value) + assert msg.endswith("Use a food whose servings all have a gram weight") + assert RETYPE_FIX not in msg + + +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): + """Every save re-derives from the kept ingredients, so they're checked + too. (A recipe with one passes the batch check only if its weight came + out right, e.g. the food was re-typed after it was saved.)""" + 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_retyped_custom_food_works_as_an_ingredient(tmp_path): + """End to end: the legacy food is refused, update_custom_food re-types + its serving, and the recipe then derives the right batch.""" + legacy = legacy_custom_food() + srv = FakeFoodServer(foods=[legacy], ingredient_foods=[legacy]) + client, _ = make_server_client(tmp_path, srv) + with pytest.raises(CronometerError, match=re.escape(RETYPE_FIX)): + client.create_recipe("R", ingredients=[(LEGACY_ID, 80)]) + + client.update_custom_food(LEGACY_ID, name="ZZ custom") + srv.ingredient_foods[LEGACY_ID] = srv.foods[LEGACY_ID] + result = client.create_recipe("R", ingredients=[(LEGACY_ID, 80)]) + + stored = srv.foods[result["food_id"]] + full = next(m for m in stored["measures"] if m["name"] == "full recipe") + assert full["value"] == 80 + energy = {n["id"]: n["amount"] for n in stored["nutrients"]}[208] + assert energy == pytest.approx(400.0) + assert result["total_grams"] == 80 + + +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): + """Stores the batch weight short while adding every ingredient's + nutrients, as the live server does for an ingredient it can't weigh.""" + + 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): + """Should the server short a batch the pre-check didn't foresee, the + read-back catches it: never report success for a wrong batch.""" + 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): + """Stores the right batch weight but one nutrient scaled by `factor`.""" + + 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): + """Live derivations match to ~1e-13; only a real error should raise.""" + 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): + """Water has no protein entry: the recipe's protein comes from the egg + alone, diluted by the water's grams.""" + 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)]) From 7726ecbf52eb2e312d79d7a1afdd0570f6ae4d71 Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 13 Oct 2026 13:20:44 -0700 Subject: [PATCH 2/3] Trim comments and docstrings Co-Authored-By: Claude Opus 5.5 --- src/cronometer_api_mcp/client.py | 73 +++++++++----------------------- tests/test_client.py | 10 ++--- tests/test_custom_food_edit.py | 13 +----- tests/test_recipe_edit.py | 32 ++------------ 4 files changed, 28 insertions(+), 100 deletions(-) diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 51cf632..fbb7c1d 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -86,11 +86,8 @@ _FULL_RECIPE_MEASURE = "full recipe" _WEIGHT_UNIT_MEASURES = frozenset({"g", "oz"}) _RECIPE_FIXED_MEASURES = _WEIGHT_UNIT_MEASURES | {_FULL_RECIPE_MEASURE} -# Measure types. Cronometer's web client saves every serving that has a gram -# weight as "Weight" (DB foods and recipes have only those). "Atomic" marks a -# count unit (a supplement "tablet"); a food with any measure not of type -# "Weight" adds its nutrients to a recipe but not its grams, so the batch -# weight comes out short (all verified live; see _check_recipe_ingredient). +# 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" _ATOMIC_MEASURE = "Atomic" # Recipe serving weights are sent rounded to this many decimals, and read back @@ -685,13 +682,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). It is saved as a "Weight" - measure, as Cronometer's web client saves a serving with a gram - weight, so the food works as a recipe ingredient (see - _check_recipe_ingredient). The server then adds a "g" measure of its - own (unless the serving is "g"), so the food can be logged in grams; - update_custom_food's per-serving edits then need the returned - measure_id. + 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 (update_custom_food needs the returned measure_id). Args: extra_nutrients: Additional nutrients beyond the core macros above @@ -911,12 +904,9 @@ def update_custom_food( Otherwise a food first saved as "400 g" keeps amount=400 and shows up as "400 " with no weight of its own. - Every save also re-types the food's weighed "Atomic" measures (older - versions of this client created servings that way) as "Weight", which - is what Cronometer's web client saves for a serving with a gram - weight. Diary math is unchanged (both count grams / 100), but only - then do the food's grams count toward a recipe's batch weight. Any - edit does it, e.g. passing the food's current name. serving_name must + Every save re-types weighed "Atomic" measures (older versions created + them) as "Weight" so the food can be a recipe ingredient; diary math + is unchanged. 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 @@ -1076,11 +1066,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. Both are read back and checked against the - ingredients, so a wrong batch raises instead of reporting success. - Ingredients whose food has a measure not of type "Weight" are refused - before saving, since the server leaves their grams out of the batch - (see _check_recipe_ingredient). + 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. @@ -1339,9 +1326,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, and both are checked after the save as in create_recipe. The - ingredients (new or kept) are refused before saving if their batch - weight can't be derived (see _check_recipe_ingredient). + 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 @@ -1436,7 +1421,6 @@ def update_recipe( serving["value"] = serving_grams _check_serving_fits(serving["value"], batch_grams) if parsed is None: - # Kept ingredients are checked too: the save re-derives from them. ingredient_foods = self._ingredient_foods( [i["foodId"] for i in food["ingredients"]] ) @@ -1497,8 +1481,7 @@ def update_recipe( return result def _ingredient_foods(self, food_ids: list[int]) -> dict[int, dict]: - """The ingredients' foods by id, each checked by _check_recipe_ingredient - before anything is saved.""" + """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]) @@ -2326,8 +2309,7 @@ def _check_serving_fits(serving_grams: float, batch_grams: float) -> None: ) -# The nutrients _check_derived_recipe recomputes from the ingredients. A -# wrong batch weight scales every nutrient alike, so these suffice. +# A wrong batch weight scales every nutrient alike, so these suffice. _RECIPE_CHECKED_NUTRIENTS = ( NUTRIENT_IDS["energy"], NUTRIENT_IDS["protein"], @@ -2337,15 +2319,9 @@ def _check_serving_fits(serving_grams: float, batch_grams: float) -> None: def _check_derived_recipe(food: dict, ingredient_foods: dict[int, dict]) -> None: - """Check what the server derived for a just-saved recipe. - - The client sends no nutrients, so energy must be present (a water-only - recipe reads back with energy 0, verified live). The "full recipe" weight - must be the ingredients' total, and energy and the macros must be the - ingredients' per-100 g amounts weighted by grams: six real recipes - matched that to float precision (checked live), so anything else is a - wrong batch rather than rounding. - """ + """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: @@ -2435,9 +2411,7 @@ def _serving_fraction(batch_grams: float, servings_per_batch: float) -> float: def _created_serving(food: dict, serving_name: str) -> dict: - """A just-created custom food's serving. Beside a "Weight" serving the - server adds a "g" measure of its own (seen live, unless the serving is - "g"), so the serving is the one measure that isn't that g.""" + """The new food's serving: the server adds a "g" beside it (seen live).""" gram = _measure_key(serving_name) == "g" found = [m for m in food["measures"] if gram or _measure_key(m["name"]) != "g"] if len(found) != 1: @@ -2500,16 +2474,9 @@ def _ingredient_rows( def _check_recipe_ingredient(food: dict) -> None: - """Refuse an ingredient whose grams Cronometer won't count in the batch. - - The server adds every ingredient's nutrients to a recipe, but its grams - only when all of the food's measures are type "Weight". With any other - (an "Atomic" serving, even beside a "Weight" g; a serving-based recipe's - "Recipe" measures are refused untested) the stored "full recipe" weight - comes out short, 0 for a lone such ingredient, so every per-100 g - nutrient is inflated (verified live: 80 g of a 400 kcal/100 g custom food - with an "Atomic" serving stored a 0 g batch at 320 kcal/100 g). - """ + """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 diff --git a/tests/test_client.py b/tests/test_client.py index 0642fea..6224fb8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -336,10 +336,8 @@ class FakeFoodServer: the first measure becomes the default (unless it's a lone "g", which keeps defaultMeasureId 0, as seen live), new measure names are re-parsed (server_reparse), and a recipe's "full recipe" weight and per-100g - nutrients are derived from its ingredients, leaving out the grams of an - ingredient with a non-"Weight" measure as the live server does - (derive_nutrients=False keeps whatever nutrients were sent, simulating a - save that derived nothing). + nutrients are derived from its ingredients (derive_nutrients=False keeps + whatever nutrients were sent, simulating a save that derived nothing). `save_responses` replaces the next add_food answers. """ @@ -406,8 +404,7 @@ 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"): - # Live: an ingredient adds its grams only if all its food's - # measures are type "Weight", but always adds its nutrients. + # Live: unweighable ingredients add nutrients but not grams. batch = sum( i["grams"] for i in food["ingredients"] @@ -1510,7 +1507,6 @@ 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) - # As the web client saves a weighed serving, so it counts in recipes. assert measure["type"] == "Weight" assert result == {"food_id": 4242, "measure_id": 9000} diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 4e26c93..8191c23 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -685,11 +685,6 @@ def test_update_custom_food_refuses_renaming_serving_to_weight_unit(tmp_path, un assert state["payloads"] == [] -# --------------------------------------------------------------------------- -# Weighed "Atomic" measures are re-typed "Weight" on every save -# --------------------------------------------------------------------------- - - def _measure(mid: int, name: str, value, mtype: str, amount: float = 1) -> dict: return {"id": mid, "name": name, "amount": amount, "value": value, "type": mtype} @@ -697,7 +692,7 @@ def _measure(mid: int, name: str, value, mtype: str, amount: float = 1) -> dict: @pytest.mark.parametrize( "kwargs", [ - {"name": "Test Food"}, # the same name: the cheapest re-save + {"name": "Test Food"}, {"name": "Renamed"}, {"calories": 120}, {"serving_quantity": 2}, @@ -705,8 +700,6 @@ def _measure(mid: int, name: str, value, mtype: str, amount: float = 1) -> dict: ], ) def test_any_edit_retypes_weighed_atomic_measures(tmp_path, kwargs): - """Older versions saved servings as "Atomic", whose grams Cronometer - leaves out of a recipe's batch weight; every save re-types them.""" food = custom_food() client, state = make_cold_client(tmp_path, [food, OK]) @@ -714,7 +707,7 @@ def test_any_edit_retypes_weighed_atomic_measures(tmp_path, kwargs): (measure,) = state["payloads"][1]["data"]["measures"] assert measure["type"] == "Weight" - assert measure["id"] == 900 # same measure, so diary entries keep it + assert measure["id"] == 900 def test_retyping_covers_every_measure_and_keeps_the_rest(tmp_path): @@ -740,8 +733,6 @@ def test_retyping_covers_every_measure_and_keeps_the_rest(tmp_path): @pytest.mark.parametrize("value", [0, None, -5, float("nan")]) def test_weightless_atomic_measures_stay_atomic(tmp_path, value): - """A count unit with no real gram weight (the web form's "n/a") can't be - a weight, so it isn't re-typed.""" food = custom_food() food["measures"].append(_measure(901, "tablet", value, "Atomic")) client, state = make_cold_client(tmp_path, [food, OK]) diff --git a/tests/test_recipe_edit.py b/tests/test_recipe_edit.py index ce331e8..1320b3b 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -25,7 +25,7 @@ RECIPE_ID = 85008439 OK = {"result": "SUCCESS", "messages": [], "id": RECIPE_ID} -# Database foods' measures are all type "Weight" (seen live on these three). +# Live, these three have only "Weight" measures. BANANA = { "id": 450856, "name": "Banana, Fresh", @@ -183,7 +183,6 @@ def endpoints(state) -> list[str]: return state["endpoints"] -# fetch, check the ingredients, save, read back SAVE = ["get_food", "get_foods", "add_food", "get_food"] @@ -816,7 +815,6 @@ def test_save_failure_body_raises_after_one_retry(tmp_path): with pytest.raises(CronometerError, match="nope"): client.update_recipe(RECIPE_ID, name="X") - # retried once assert endpoints(state) == ["get_food", "get_foods", "add_food", "add_food"] @@ -1800,16 +1798,11 @@ def handle(self, endpoint, payload): assert srv.foods[RECIPE_ID]["name"] == "X" -# --------------------------------------------------------------------------- -# Ingredients Cronometer can't weigh, and checking the derived batch -# --------------------------------------------------------------------------- - LEGACY_ID = 85102741 def legacy_custom_food(*measures: dict) -> dict: - """A label-only custom food (400 kcal/100 g) as older versions of this - client saved it: an "Atomic" serving with a gram weight.""" + """400 kcal/100 g, with the "Atomic" serving older versions saved.""" return { "id": LEGACY_ID, "name": "ZZ custom", @@ -1832,10 +1825,7 @@ def _m(mid, name, value, mtype): UNWEIGHABLE = { - # The live repro: a lone weighed Atomic serving. "atomic serving": legacy_custom_food(), - # Live: a "Weight" g beside an Atomic serving still isn't counted, nor is - # an Atomic g beside a "Weight" serving. "atomic serving, weight g": legacy_custom_food( _m(7, "serving", 100, "Atomic"), _m(8, "g", 1, "Weight") ), @@ -1867,12 +1857,9 @@ def test_create_recipe_refuses_unweighable_ingredient_before_saving(tmp_path, fo @pytest.mark.parametrize( "food", [ - # A database supplement whose "tablet" is a count, not a weight. {**legacy_custom_food(_m(7, "tablet", 100, "Atomic")), "source": "CRDB"}, - # A custom count unit with no gram weight (the web form's "n/a"). legacy_custom_food(_m(7, "serving", 0, "Atomic")), legacy_custom_food(_m(7, "serving", 100, "Atomic"), _m(8, "x", None, "Atomic")), - # A serving-based recipe's measures. legacy_custom_food(_m(7, "serving", 1, "Recipe"), _m(8, "g", 200, "Recipe")), ], ids=["database tablet", "weightless", "partly weightless", "serving-based"], @@ -1905,9 +1892,6 @@ def test_update_recipe_refuses_unweighable_new_ingredient(tmp_path): "kwargs", [{"name": "X"}, {"comments": "c"}, {"serving_grams": 50}] ) def test_update_recipe_refuses_unweighable_kept_ingredient(tmp_path, kwargs): - """Every save re-derives from the kept ingredients, so they're checked - too. (A recipe with one passes the batch check only if its weight came - out right, e.g. the food was re-typed after it was saved.)""" food = recipe() food["ingredients"][1]["foodId"] = LEGACY_ID srv = FakeFoodServer(foods=[food], ingredient_foods=[BANANA, legacy_custom_food()]) @@ -1920,8 +1904,6 @@ def test_update_recipe_refuses_unweighable_kept_ingredient(tmp_path, kwargs): def test_retyped_custom_food_works_as_an_ingredient(tmp_path): - """End to end: the legacy food is refused, update_custom_food re-types - its serving, and the recipe then derives the right batch.""" legacy = legacy_custom_food() srv = FakeFoodServer(foods=[legacy], ingredient_foods=[legacy]) client, _ = make_server_client(tmp_path, srv) @@ -1953,8 +1935,7 @@ def test_repeated_ingredient_food_is_fetched_and_counted(tmp_path): class ShortBatchServer(FakeFoodServer): - """Stores the batch weight short while adding every ingredient's - nutrients, as the live server does for an ingredient it can't weigh.""" + """Treats `uncounted` as unweighable, as the live server does.""" def __init__(self, *args, uncounted: int, **kwargs): super().__init__(*args, **kwargs) @@ -1978,8 +1959,6 @@ def _save(self, food): ids=["lone", "beside a counted one"], ) def test_create_recipe_raises_on_a_short_batch(tmp_path, ingredients, stored): - """Should the server short a batch the pre-check didn't foresee, the - read-back catches it: never report success for a wrong batch.""" srv = ShortBatchServer(ingredient_foods=[EGG, WATER], uncounted=1234) client, state = make_server_client(tmp_path, srv) @@ -2011,8 +1990,6 @@ def test_update_recipe_raises_on_a_short_batch(tmp_path): class ScaledNutrientServer(FakeFoodServer): - """Stores the right batch weight but one nutrient scaled by `factor`.""" - def __init__(self, *args, nutrient: int, factor: float, **kwargs): super().__init__(*args, **kwargs) self.nutrient, self.factor = nutrient, factor @@ -2057,7 +2034,6 @@ def test_update_recipe_raises_on_wrong_derived_nutrients(tmp_path, nutrient): @pytest.mark.parametrize("factor", [1 + 1e-9, 1 - 1e-9, 1 + 9e-5]) def test_float_noise_in_derived_nutrients_passes(tmp_path, factor): - """Live derivations match to ~1e-13; only a real error should raise.""" srv = ScaledNutrientServer(ingredient_foods=[EGG], nutrient=208, factor=factor) client, _ = make_server_client(tmp_path, srv) @@ -2065,8 +2041,6 @@ def test_float_noise_in_derived_nutrients_passes(tmp_path, factor): def test_nutrients_the_ingredients_lack_count_as_zero(tmp_path): - """Water has no protein entry: the recipe's protein comes from the egg - alone, diluted by the water's grams.""" srv = FakeFoodServer(ingredient_foods=[EGG, WATER]) client, _ = make_server_client(tmp_path, srv) From ebb0b1f7be9a2168b14f624c544b88e22f9d65bc Mon Sep 17 00:00:00 2001 From: Colin Toft Date: Tue, 13 Oct 2026 13:25:24 -0700 Subject: [PATCH 3/3] Pick a custom food's serving beside the server's g; drop the Atomic re-type shim A Weight serving gets a server-added g, so update_custom_food's per-serving edits on new foods needed measure_id. _custom_food_serving now ignores g/oz when exactly one other measure remains, replacing _created_serving. Older custom foods with Atomic servings are refused as recipe ingredients rather than silently re-typed on any edit; they get a one-time migration. Co-Authored-By: Claude Opus 5.5 --- src/cronometer_api_mcp/client.py | 46 ++++--------------- src/cronometer_api_mcp/server.py | 7 ++- tests/test_client.py | 34 ++++++++------ tests/test_custom_food_edit.py | 79 +++++++++++++++++--------------- tests/test_food_log.py | 2 +- tests/test_recipe_edit.py | 28 +---------- 6 files changed, 78 insertions(+), 118 deletions(-) diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index fbb7c1d..b9d7169 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -89,7 +89,6 @@ # 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" -_ATOMIC_MEASURE = "Atomic" # 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. @@ -684,7 +683,7 @@ def create_custom_food( refused. The saved serving is read back and re-sent once if the server 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 (update_custom_food needs the returned measure_id). + a "g" measure. Args: extra_nutrients: Additional nutrients beyond the core macros above @@ -778,7 +777,7 @@ def create_custom_food( food = self.get_food(food_id) _, serving = self._settle_serving( food, - _created_serving(food, serving_name)["id"], + _custom_food_serving(food, None)["id"], serving_name, serving_quantity, serving_grams, @@ -904,9 +903,7 @@ def update_custom_food( Otherwise a food first saved as "400 g" keeps amount=400 and shows up as "400 " with no weight of its own. - Every save re-types weighed "Atomic" measures (older versions created - them) as "Weight" so the food can be a recipe ingredient; diary math - is unchanged. serving_name must + 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 @@ -981,9 +978,6 @@ def update_custom_food( measure["amount"] = ( serving_quantity if serving_quantity is not None else 1.0 ) - for m in food["measures"]: - if m["type"] == _ATOMIC_MEASURE and _is_positive_number(m["value"]): - m["type"] = _WEIGHED_MEASURE updates = {} if per_serving: grams = measure["value"] @@ -2410,22 +2404,10 @@ def _serving_fraction(batch_grams: float, servings_per_batch: float) -> float: return round(batch_grams / servings_per_batch, _GRAMS_DECIMALS) -def _created_serving(food: dict, serving_name: str) -> dict: - """The new food's serving: the server adds a "g" beside it (seen live).""" - gram = _measure_key(serving_name) == "g" - found = [m for m in food["measures"] if gram or _measure_key(m["name"]) != "g"] - if len(found) != 1: - raise CronometerError( - f"Custom food {food['id']} read back with measures " - f"{[(m['id'], m['name'], m['value']) for m in food['measures']]}; " - f"expected its serving {serving_name!r} and at most Cronometer's g" - ) - return found[0] - - 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: @@ -2435,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) " @@ -2480,22 +2465,11 @@ def _check_recipe_ingredient(food: dict) -> None: others = [m for m in food["measures"] if m["type"] != _WEIGHED_MEASURE] if not others: return - if food.get("source") == "Custom" and all( - m["type"] == _ATOMIC_MEASURE and _is_positive_number(m["value"]) for m in others - ): - fix = ( - "It's a custom food whose servings an older version of this tool " - "saved as 'Atomic'; any update_custom_food edit (e.g. passing its " - "current name) re-saves them as 'Weight', which fixes this" - ) - else: - fix = "Use a food whose servings all have a gram weight" 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. " - f"{fix}" + f"recipe's batch weight, which would inflate its per-100 g nutrients" ) diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index f135733..4e50172 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -1067,8 +1067,7 @@ 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; pass the returned measure_id to update_custom_food's per-serving - edits. The food can be a recipe ingredient (add_recipe). + grams. The food can be a recipe ingredient (add_recipe). Args: name: Food name. @@ -1277,8 +1276,8 @@ def add_recipe( 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. A custom food saved by an older version of this tool is - one of those until any update_custom_food edit re-saves it. + before saving, including custom foods made before Oct 13, 2026 + ("Atomic" servings). Args: name: Recipe name. diff --git a/tests/test_client.py b/tests/test_client.py index 6224fb8..8f395c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1609,18 +1609,10 @@ def test_create_custom_food_failure_after_save_names_the_id( @pytest.mark.parametrize( ("break_read_back", "match"), [ - (lambda food: food.update(measures=[]), r"read back with measures \[\];"), + (lambda food: food.update(measures=[]), "has no serving measure"), ( lambda food: food["measures"].append({**food["measures"][0], "id": 1}), - ( - r"read back with measures \[\(9000, 'serving', 100.0\), " - r"\(9001, 'g', 1\), \(1, 'serving', 100.0\)\]; expected its " - r"serving 'serving' and at most Cronometer's g" - ), - ), - ( - lambda food: food["measures"].pop(0), - r"read back with measures \[\(9001, 'g', 1\)\]", + r"has 3 measures \(id, name, grams\) \[\(9000, 'serving', 100", ), ], ) @@ -1700,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"] @@ -1756,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)) @@ -1766,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 8191c23..b358396 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -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 @@ -685,59 +687,60 @@ def test_update_custom_food_refuses_renaming_serving_to_weight_unit(tmp_path, un assert state["payloads"] == [] -def _measure(mid: int, name: str, value, mtype: str, amount: float = 1) -> dict: - return {"id": mid, "name": name, "amount": amount, "value": value, "type": mtype} +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( - "kwargs", + ("measures", "picked"), [ - {"name": "Test Food"}, - {"name": "Renamed"}, - {"calories": 120}, - {"serving_quantity": 2}, - {"serving_grams": 40}, + ([_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_any_edit_retypes_weighed_atomic_measures(tmp_path, kwargs): +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, **kwargs) + client.update_custom_food(FOOD_ID, calories=100) - (measure,) = state["payloads"][1]["data"]["measures"] - assert measure["type"] == "Weight" - assert measure["id"] == 900 + 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) -def test_retyping_covers_every_measure_and_keeps_the_rest(tmp_path): +@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"] = [ - _measure(900, "serving", 50, "Atomic"), - _measure(901, "cookies", 30, "Atomic", amount=2), - _measure(902, "g", 1, "Atomic"), - _measure(903, "cup", 240, "Weight"), - ] - client, state = make_cold_client(tmp_path, [food, OK]) - - client.update_custom_food(FOOD_ID, name="Renamed") + food["measures"] = measures + client, state = make_cold_client(tmp_path, [food]) - sent = state["payloads"][1]["data"]["measures"] - assert [(m["id"], m["amount"], m["value"], m["type"]) for m in sent] == [ - (900, 1, 50, "Weight"), - (901, 2, 30, "Weight"), - (902, 1, 1, "Weight"), - (903, 1, 240, "Weight"), - ] + with pytest.raises(CronometerError, match="pass measure_id"): + client.update_custom_food(FOOD_ID, calories=100) + assert len(state["payloads"]) == 1 -@pytest.mark.parametrize("value", [0, None, -5, float("nan")]) -def test_weightless_atomic_measures_stay_atomic(tmp_path, value): +def test_update_leaves_measure_types_alone(tmp_path): food = custom_food() - food["measures"].append(_measure(901, "tablet", value, "Atomic")) client, state = make_cold_client(tmp_path, [food, OK]) client.update_custom_food(FOOD_ID, name="Renamed") - sent = state["payloads"][1]["data"]["measures"] - assert [(m["id"], m["type"]) for m in sent] == [(900, "Weight"), (901, "Atomic")] + (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 1320b3b..d5d7b45 100644 --- a/tests/test_recipe_edit.py +++ b/tests/test_recipe_edit.py @@ -8,7 +8,6 @@ import copy import json import math -import re import httpx import pytest @@ -1833,7 +1832,6 @@ def _m(mid, name, value, mtype): _m(7, "serving", 100, "Weight"), _m(8, "g", 1, "Atomic") ), } -RETYPE_FIX = "any update_custom_food edit (e.g. passing its current name)" @pytest.mark.parametrize("food", UNWEIGHABLE.values(), ids=UNWEIGHABLE.keys()) @@ -1849,7 +1847,6 @@ def test_create_recipe_refuses_unweighable_ingredient_before_saving(tmp_path, fo 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 RETYPE_FIX in msg assert state["endpoints"] == ["get_foods"] # nothing created assert srv.foods == {} @@ -1864,16 +1861,14 @@ def test_create_recipe_refuses_unweighable_ingredient_before_saving(tmp_path, fo ], ids=["database tablet", "weightless", "partly weightless", "serving-based"], ) -def test_unfixable_ingredients_suggest_another_food(tmp_path, food): +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)]) - msg = str(err.value) - assert msg.endswith("Use a food whose servings all have a gram weight") - assert RETYPE_FIX not in msg + assert "can't be a recipe ingredient" in str(err.value) def test_update_recipe_refuses_unweighable_new_ingredient(tmp_path): @@ -1903,25 +1898,6 @@ def test_update_recipe_refuses_unweighable_kept_ingredient(tmp_path, kwargs): assert "add_food" not in endpoints(state) -def test_retyped_custom_food_works_as_an_ingredient(tmp_path): - legacy = legacy_custom_food() - srv = FakeFoodServer(foods=[legacy], ingredient_foods=[legacy]) - client, _ = make_server_client(tmp_path, srv) - with pytest.raises(CronometerError, match=re.escape(RETYPE_FIX)): - client.create_recipe("R", ingredients=[(LEGACY_ID, 80)]) - - client.update_custom_food(LEGACY_ID, name="ZZ custom") - srv.ingredient_foods[LEGACY_ID] = srv.foods[LEGACY_ID] - result = client.create_recipe("R", ingredients=[(LEGACY_ID, 80)]) - - stored = srv.foods[result["food_id"]] - full = next(m for m in stored["measures"] if m["name"] == "full recipe") - assert full["value"] == 80 - energy = {n["id"]: n["amount"] for n in stored["nutrients"]}[208] - assert energy == pytest.approx(400.0) - assert result["total_grams"] == 80 - - 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)