diff --git a/backend/app/services/open_food_facts.py b/backend/app/services/open_food_facts.py index fff61e3..1d87898 100644 --- a/backend/app/services/open_food_facts.py +++ b/backend/app/services/open_food_facts.py @@ -57,16 +57,18 @@ def _repair_common_mojibake(text: str) -> str: return text -def _to_float(value: Any) -> float: +def _to_float(value: Any) -> float | None: if value is None: - return 0.0 + return None try: result = float(value) if not math.isfinite(result): - return 0.0 + return None + if result < 0: + return None return round(result, 2) except (TypeError, ValueError): - return 0.0 + return None def _to_optional_text(value: Any) -> str | None: @@ -135,13 +137,26 @@ async def search_food_products(query: str, page_size: int = 10) -> list[FoodSear continue nutriments = product.get("nutriments") or {} + nutrition = { + "calories": _to_float(nutriments.get("energy-kcal_100g")), + "protein": _to_float(nutriments.get("proteins_100g")), + "fat": _to_float(nutriments.get("fat_100g")), + "carbohydrates": _to_float(nutriments.get("carbohydrates_100g")), + } + + # A missing value is not the same as a measured zero. Incomplete + # records are excluded from the loggable search results so CalorieApp + # cannot silently turn unknown nutrition into a misleading 0.0 value. + if any(value is None for value in nutrition.values()): + continue + results.append( FoodSearchResult( product_name=product_name, - calories=_to_float(nutriments.get("energy-kcal_100g")), - protein=_to_float(nutriments.get("proteins_100g")), - fat=_to_float(nutriments.get("fat_100g")), - carbohydrates=_to_float(nutriments.get("carbohydrates_100g")), + calories=nutrition["calories"], + protein=nutrition["protein"], + fat=nutrition["fat"], + carbohydrates=nutrition["carbohydrates"], image_url=_extract_image_url(product), barcode=_to_optional_text(product.get("code")), brand=_extract_brand(product), diff --git a/backend/tests/test_open_food_facts_normalization.py b/backend/tests/test_open_food_facts_normalization.py index c2fc3cc..4a15524 100644 --- a/backend/tests/test_open_food_facts_normalization.py +++ b/backend/tests/test_open_food_facts_normalization.py @@ -12,10 +12,18 @@ ) -@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan"), "Infinity", "NaN"]) -def test_to_float_rejects_non_finite_upstream_values(value: object) -> None: +@pytest.mark.parametrize( + "value", + [None, float("inf"), float("-inf"), float("nan"), "Infinity", "NaN", -1], +) +def test_to_float_marks_missing_or_invalid_upstream_values_as_unknown(value: object) -> None: + assert _to_float(value) is None + + +@pytest.mark.parametrize("value", [0, "0", 12.345]) +def test_to_float_preserves_real_finite_non_negative_values(value: object) -> None: result = _to_float(value) - assert result == 0.0 + assert result is not None assert math.isfinite(result) @@ -30,6 +38,35 @@ def test_extract_nutri_score_only_returns_supported_grades( assert _extract_nutri_score({"nutriscore_grade": value}) == expected +@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +def test_search_omits_products_with_unknown_nutrition(primary: AsyncMock) -> None: + primary.return_value = { + "products": [ + { + "product_name": "Incomplete oats", + "nutriments": { + "energy-kcal_100g": 375, + "proteins_100g": 13, + "fat_100g": 7, + }, + }, + { + "product_name": "Complete oats", + "nutriments": { + "energy-kcal_100g": 375, + "proteins_100g": 13, + "fat_100g": 7, + "carbohydrates_100g": 60, + }, + }, + ] + } + + results = asyncio.run(search_food_products("oats")) + + assert [result.product_name for result in results] == ["Complete oats"] + + @patch("app.services.open_food_facts._fetch_fallback", new_callable=AsyncMock) @patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) def test_expected_fallback_failure_becomes_upstream_http_error( diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx index 2a9908e..e1fe0ba 100644 --- a/frontend/components/FoodSearchPlaceholder.tsx +++ b/frontend/components/FoodSearchPlaceholder.tsx @@ -25,9 +25,9 @@ type PortionOption = "whole" | "half" | "quarter" | "custom"; const SIGN_IN_REQUIRED_LOG_MESSAGE = "Your session has expired or you are not signed in. Please sign in again to manage food logs."; -function toNumber(value: unknown): number { - if (typeof value !== "number" || Number.isNaN(value)) { - return 0; +function toNumber(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; } return value; } @@ -47,21 +47,34 @@ function toOptionalNumber(value: unknown): number | undefined { return value; } -function normalizeFoodItem(value: unknown): FoodSearchItem { +function normalizeFoodItem(value: unknown): FoodSearchItem | null { const raw = (value ?? {}) as Record; const productName = typeof raw.product_name === "string" && raw.product_name.trim().length > 0 ? raw.product_name : "Unknown food"; + const calories = toNumber(raw.calories); + const protein = toNumber(raw.protein); + const fat = toNumber(raw.fat); + const carbohydrates = toNumber(raw.carbohydrates); + + if ( + calories === null || + protein === null || + fat === null || + carbohydrates === null + ) { + return null; + } return { id: toOptionalNumber(raw.id), created_at: toOptionalText(raw.created_at), product_name: productName, - calories: toNumber(raw.calories), - protein: toNumber(raw.protein), - fat: toNumber(raw.fat), - carbohydrates: toNumber(raw.carbohydrates), + calories, + protein, + fat, + carbohydrates, portion_percentage: toOptionalNumber(raw.portion_percentage), image_url: toOptionalText(raw.image_url), barcode: toOptionalText(raw.barcode), @@ -71,6 +84,12 @@ function normalizeFoodItem(value: unknown): FoodSearchItem { }; } +function normalizeFoodItems(values: unknown[]): FoodSearchItem[] { + return values + .map(normalizeFoodItem) + .filter((item): item is FoodSearchItem => item !== null); +} + function getPortionPercentage(option: PortionOption, customValue: string): number | null { if (option === "whole") { return 100; @@ -300,7 +319,7 @@ export function FoodSearchPlaceholder() { if (requestId !== logsRequestIdRef.current) { return; } - setLogs((data ?? []).map(normalizeFoodItem)); + setLogs(normalizeFoodItems(data ?? [])); setLogError(null); } catch (requestError) { if (requestId === logsRequestIdRef.current) { @@ -393,7 +412,7 @@ export function FoodSearchPlaceholder() { if (requestId !== searchRequestIdRef.current) { return; } - setResults((data.results ?? []).map(normalizeFoodItem)); + setResults(normalizeFoodItems(data.results ?? [])); } catch (requestError) { if (!controller.signal.aborted && requestId === searchRequestIdRef.current) { setResults([]); @@ -565,6 +584,9 @@ export function FoodSearchPlaceholder() {

Explore product nutrition data provided by Open Food Facts.

+

+ Only records with complete calorie, protein, fat, and carbohydrate values are shown. +

) : null}